HTMLの基礎

HTMLってなに?

  • Webページの骨組みを作る言語(見出し・段落・画像・リンクなどの構造)。
  • 見た目はCSS、動きはJSが担当。HTMLは“意味と構造”。

まずは雛形(コピペOK)

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>はじめてのHTML</title>
  <meta name="description" content="このページの説明文" />
</head>
<body>
  <h1>こんにちは!</h1>
  <p>これは最小構成のHTML5ページです。</p>
</body>
</html>

よく使う要素(超基本)

  • 見出し:<h1>~<h6>(ページに基本はh1は1つ
  • 段落:<p>
  • リンク:<a href="https://example.com">リンク</a>
  • 画像:<img src="img.png" alt="画像の説明">alt必須
  • リスト:<ul><li>…</li></ul> / <ol>…</ol>
  • 強調:<strong>(重要) / <em>(強調)
  • 区切り:<br>(改行は最小限)、<hr>(区切り線)
  • まとまり:<div>(汎用ブロック)、<span>(汎用インライン)

セマンティック要素(構造をわかりやすく)

  • header(ヘッダー)
  • nav(ナビ)
  • main(主内容は1ページ1つ
  • section(章)
  • article(単体で完結する記事)
  • aside(補足)
  • footer(フッター)

属性のキホン

  • id(一意な識別子)/class(グループ化)
  • href(リンク先)/src(画像・スクリプト元)
  • alt(画像代替文)/title(補足ヒント)
  • target="_blank"rel="noopener noreferrer"とセットで

フォーム最小例

<form action="/search" method="get">
  <label for="q">検索:</label>
  <input id="q" name="q" type="search" required>
  <button type="submit">送信</button>
</form>

テーブル最小例(表)

<table>
  <thead><tr><th>商品</th><th>価格</th></tr></thead>
  <tbody>
    <tr><td>りんご</td><td>120</td></tr>
    <tr><td>みかん</td><td>100</td></tr>
  </tbody>
</table>

CSS / JS の読み込み

<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
  • deferはHTML解析後に実行(推奨)。

ちょっとだけ“実践的”なサンプル

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>ミニサイト</title>
  <style>
    body { font-family: system-ui, sans-serif; line-height: 1.7; margin: 0; }
    header, footer { padding: 16px; background: #f5f5f5; }
    nav a { margin-right: 12px; }
    main { max-width: 920px; margin: 24px auto; padding: 0 16px; }
    img { max-width: 100%; height: auto; }
    .card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
  </style>
</head>
<body>
  <header>
    <h1>ミニサイト</h1>
    <nav>
      <a href="#about">概要</a>
      <a href="#gallery">ギャラリー</a>
      <a href="#contact">お問い合わせ</a>
    </nav>
  </header>

  <main id="content">
    <section id="about">
      <h2>概要</h2>
      <p>これはHTMLの基本で作ったミニページです。</p>
    </section>

    <section id="gallery">
      <h2>ギャラリー</h2>
      <div class="card">
        <img src="sample.jpg" alt="サンプル画像">
        <p>レスポンシブに画像が縮みます。</p>
      </div>
      <ul>
        <li>箇条書き1</li>
        <li>箇条書き2</li>
      </ul>
    </section>

    <section id="contact">
      <h2>お問い合わせ</h2>
      <form>
        <label for="name">お名前</label><br>
        <input id="name" name="name" required><br><br>
        <label for="msg">メッセージ</label><br>
        <textarea id="msg" name="msg" rows="4"></textarea><br><br>
        <button type="submit">送信</button>
      </form>
    </section>
  </main>

  <footer>
    <small>&copy; 2025 MiniSite</small>
  </footer>

  <script>
    // ごく簡単なJS:ナビをクリックしたらスムーズスクロール
    document.querySelectorAll('nav a').forEach(a => {
      a.addEventListener('click', e => {
        const id = a.getAttribute('href');
        if (id.startsWith('#')) {
          e.preventDefault();
          document.querySelector(id)?.scrollIntoView({ behavior: 'smooth' });
        }
      });
    });
  </script>
</body>
</html>

初心者がつまずきやすいポイント

  • 文字化け→<meta charset="utf-8">を必ず入れる。
  • スマホで拡大縮小が変→<meta name="viewport" …>を入れる。
  • 画像が大きすぎる→CSSでimg { max-width: 100%; height: auto; }
  • 見出し乱用→h1はページの主題に1回、階層は順序を守る。
  • altなし→スクリーンリーダー/SEO的にマイナス。必ず書く。

もっと深掘り(フォームのバリデーション、SEO、アクセシビリティ、Flex/Gridレイアウト、コンポーネント化など)もまとめられます。どこから強化したい?(例:フォームをしっかり、レイアウトを学ぶ、CSS設計、JS連携 など)

自民党と維新の会で連立することによって行われる政策

まず実行に移されやすい項目(重なり大)

  • 憲法改正手続きの前進(国会での発議模索)
    両党とも改憲に前向き。まずは「緊急事態条項」「統治機構改革」など、条文案の絞り込みと発議戦略づくりが動く可能性が高い(発議には各院の3分の2→国民投票が必要)。フィナンシャル・タイムズ+1
  • 教育費の軽減・無償化の拡大
    維新は看板で「教育の無償化」を掲げており、高校授業料支援の拡充や私学支援の強化、所得制限の緩和・撤廃が加速しやすい。すでに私立高向け支援拡充の合意報道もあり、制度の横展開が想定。日本維新の会+1
  • 規制改革・行政改革(小さな政府志向)
    行政のスリム化、デジタル化、特区の拡充、参入規制の見直しなどは維新色と自民の成長戦略が重なる。まずは所管省庁に対する見直し指示と規制棚卸しから。フィナンシャル・タイムズ
  • 地方分権・大阪を軸にした「二極化」構想の推進
    「大阪を第二の中枢拠点に」の発信強化、政府機能の分散や省庁・機関の一部移転、地方への権限移譲の工程表づくり。維新の分権理念と親和性が高い。フィナンシャル・タイムズ+1
  • 防衛力整備の継続(対GDP2%水準の着実化)
    既定方針の予算・装備計画を前に進める方向で一致しやすい。論点は主に財源配分だが、方向性自体は両党で大きな齟齬は少ない。東京財団
  • 物価・景気対策の補正予算
    新内閣発足直後の補正で賃上げ・家計支援・投資減税・インフラ更新などを束ねる可能性。市場は追加財政を織り込み済みとの観測。フィナンシャル・タイムズ

中期で進む可能性が高い項目

  • 社会保障の“世代間公平化”
    維新は高齢者の窓口負担を現役並みに近づけるなど“応能負担”を重視。自民は慎重だが、医療費の適正化や給付と負担の見直しは避けて通れず、段階的な制度改正(高額療養費の見直し等)から着手の公算。日本維新の会
  • マイナンバー連携やデジタル行財政改革
    手続きのワンストップ化・データ連携強化・給付のプッシュ型化など、既存法の改正・運用改善で実行可能。自由民主党

衝突・慎重論点(実装にブレーキがかかり得る)

  • 改憲の“条文の中身”
    緊急事態条項は比較的合意が得やすい一方、9条や安全保障関連の書きぶり、国民投票の時期は与野党・世論の反応次第で難航し得る。フィナンシャル・タイムズ
  • 社会保障負担のシフト
    維新案(高齢者負担増や給付重点の再配分)は反発が大きく、自民の支持基盤・公的世論との調整が必要。スピード感は限定的かも。日本維新の会
  • 移民・難民・労働市場政策
    受け入れ拡大の是非や技能制度の設計は、自民執行部の治安・保守志向と維新の成長・労働市場重視の綱引きが想定され、文言調整に時間。ガーディアン

QuestFoundry

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Quest Foundry | 世界観からクエスト自動設計</title>
  <meta name="description" content="世界観のキーワードからNPC・アイテム・場所・クエストを一括生成。JSON/CSVエクスポート、依存関係、難易度バランス、シード固定対応。" />
  <!-- Tailwind CDN (Node不要) -->
  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    tailwind.config = {
      theme: {
        extend: {
          fontFamily: { sans: ["Noto Sans JP", "ui-sans-serif", "system-ui"] },
          colors: { brand: { 50: '#eef2ff', 100:'#e0e7ff', 200:'#c7d2fe', 300:'#a5b4fc', 400:'#818cf8', 500:'#6366f1', 600:'#4f46e5', 700:'#4338ca', 800:'#3730a3', 900:'#312e81'} }
        }
      }
    };
  </script>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
  <style>
    html, body { height: 100%; }
    .glass { backdrop-filter: blur(10px); background: rgba(255,255,255,0.7); }
    .prose pre { white-space: pre-wrap; word-break: break-word; }
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
    .card { @apply rounded-2xl shadow-lg p-5 bg-white; }
      .prose h1{font-size:1.5rem;line-height:1.3;margin:0 0 .6rem;font-weight:800}
    .prose h2{font-size:1.2rem;line-height:1.35;margin:1.2rem 0 .4rem;font-weight:700;border-left:4px solid #6366f1;padding-left:.6rem}
    .prose h3{font-size:1rem;line-height:1.4;margin:1rem 0 .3rem;font-weight:700}
    .prose ul{list-style:disc;padding-left:1.25rem;margin:.4rem 0 .8rem}
    .prose li{margin:.2rem 0}
    .badge{display:inline-block;font-size:.72rem;line-height:1;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;border-radius:.5rem;padding:.15rem .45rem;margin-right:.25rem}
    details.quest{border:1px solid #e5e7eb;border-radius:.75rem;padding:.6rem .8rem;margin:.5rem 0;background:#fff}
    details.quest > summary{cursor:pointer;list-style:none}
    details.quest > summary::-webkit-details-marker{display:none}
    .kv{display:inline-grid;grid-template-columns:auto auto;gap:.2rem .6rem;align-items:center}
  </style>
</head>
<body class="min-h-screen bg-gradient-to-br from-brand-50 to-white text-slate-800">
  <header class="sticky top-0 z-40 border-b bg-white/80 backdrop-blur">
    <div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-4">
      <div class="text-2xl font-black tracking-tight"><span class="text-brand-700">Quest</span> Foundry</div>
      <div class="text-xs text-slate-500">世界観→NPC/アイテム/場所/クエストを自動生成(JSON/CSV出力可)</div>
      <div class="ml-auto flex items-center gap-2">
        <button id="btnSave" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">保存</button>
        <button id="btnLoad" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">読込</button>
        <button id="btnPrint" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">印刷/PDF</button>
      </div>
    </div>
  </header>

  <main class="mx-auto max-w-7xl px-4 py-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
    <!-- 左:設定フォーム -->
    <section class="lg:col-span-1 card">
      <h2 class="text-lg font-bold mb-4">ワールド設定</h2>
      <form id="worldForm" class="space-y-4">
        <div>
          <label class="block text-sm font-medium">世界名</label>
          <input id="worldName" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:アトラティア" />
        </div>
        <div>
          <label class="block text-sm font-medium">テーマ・キーワード(読点・スペース区切り)</label>
          <input id="themes" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:古代遺跡 砂漠 精霊 冒険者ギルド" />
        </div>
        <div class="grid grid-cols-2 gap-4">
          <div>
            <label class="block text-sm font-medium">難易度</label>
            <select id="difficulty" class="w-full mt-1 rounded-lg border px-3 py-2">
              <option value="easy">Easy</option>
              <option value="normal" selected>Normal</option>
              <option value="hard">Hard</option>
              <option value="epic">Epic</option>
            </select>
          </div>
          <div>
            <label class="block text-sm font-medium">クエスト数</label>
            <input id="questCount" type="number" min="1" max="30" value="8" class="w-full mt-1 rounded-lg border px-3 py-2" />
          </div>
        </div>
        <div class="grid grid-cols-2 gap-4">
          <div>
            <label class="block text-sm font-medium">シード(同じ結果を再現)</label>
            <input id="seed" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="未入力なら自動" />
          </div>
          <div class="flex items-end gap-2">
            <input id="lockSeed" type="checkbox" class="h-5 w-5" />
            <label for="lockSeed" class="text-sm">シード固定(再生成でも変化しない)</label>
          </div>
        </div>
        <div>
          <label class="block text-sm font-medium">トーン</label>
          <select id="tone" class="w-full mt-1 rounded-lg border px-3 py-2">
            <option value="classic" selected>古典ファンタジー</option>
            <option value="dark">ダーク</option>
            <option value="steampunk">スチームパンク</option>
            <option value="myth">神話/叙事詩</option>
            <option value="sci">サイファンタジー</option>
          </select>
        </div>
        <div class="flex flex-wrap gap-2 pt-2">
          <button id="btnGenerate" type="button" class="px-4 py-2 rounded-xl bg-brand-600 text-white hover:bg-brand-700">生成</button>
          <button id="btnRegenerate" type="button" class="px-4 py-2 rounded-xl bg-slate-800 text-white hover:bg-slate-900">再生成(同条件)</button>
          <button id="btnShuffleSeed" type="button" class="px-4 py-2 rounded-xl border">シード再抽選</button>
        </div>
      </form>
      <p class="text-xs text-slate-500 mt-4">※外部API不使用。テンプレート×確率モデルでローカル生成。ブラウザ上で完結。</p>
    </section>

    <!-- 中央:結果(テキスト) -->
    <section class="lg:col-span-2 card">
      <div class="flex items-center gap-2 mb-4">
        <h2 class="text-lg font-bold">生成結果</h2>
        <span id="meta" class="ml-auto text-xs text-slate-500"></span>
      </div>
      <div class="flex flex-wrap gap-2 mb-4">
        <button id="btnCopyText" class="px-3 py-2 rounded-lg border">テキストをコピー</button>
        <button id="btnDownloadJSON" class="px-3 py-2 rounded-lg border">JSONダウンロード</button>
        <button id="btnExportCSV" class="px-3 py-2 rounded-lg border">CSV書き出し</button>
        <button id="btnToggleJson" class="px-3 py-2 rounded-lg border">JSON表示切替</button>
      </div>
      <div id="outText" class="prose max-w-none text-sm leading-6"></div>
      <details id="jsonBlock" class="mt-4 hidden">
        <summary class="cursor-pointer select-none text-sm text-slate-600">JSON表示</summary>
        <pre id="outJSON" class="mono text-xs bg-slate-50 p-3 rounded-lg overflow-x-auto"></pre>
      </details>
    </section>

    <!-- 下:プレビュー(カードレイアウト) -->
    <section class="lg:col-span-3 card">
      <h2 class="text-lg font-bold mb-4">カードビュー</h2>
      <div class="grid md:grid-cols-3 gap-4" id="cards"></div>
    </section>
  </main>

  <footer class="py-8 text-center text-xs text-slate-500">
    &copy; 2025 Quest Foundry — Local-first Fantasy Content Generator
  </footer>

  <script>
    /* =========================
     *  乱数とユーティリティ
     * ========================= */
    function cyrb128(str){ let h1=1779033703,h2=3144134277,h3=1013904242,h4=2773480762; for(let i=0;i<str.length;i++){ let k=str.charCodeAt(i); h1=h2^(Math.imul(h1^k,597399067)); h2=h3^(Math.imul(h2^k,2869860233)); h3=h4^(Math.imul(h3^k,951274213)); h4=h1^(Math.imul(h4^k,2716044179)); } h1=Math.imul(h3^(h1>>>18),597399067); h2=Math.imul(h4^(h2>>>22),2869860233); h3=Math.imul(h1^(h3>>>17),951274213); h4=Math.imul(h2^(h4>>>19),2716044179); let r=(h1^h2^h3^h4)>>>0; return r.toString(36); }
    function mulberry32(a){ return function(){ let t=a+=0x6D2B79F5; t=Math.imul(t^(t>>>15), t|1); t^=t+Math.imul(t^(t>>>7), t|61); return ((t^(t>>>14))>>>0)/4294967296; } }
    function rngFromSeed(seed){ let n=0; for(const ch of seed) n=(n*31 + ch.charCodeAt(0))>>>0; return mulberry32(n||1); }
    function choice(r, arr){ return arr[Math.floor(r()*arr.length)] }
    function pickN(r, arr, n){ const a=[...arr]; const out=[]; for(let i=0;i<n && a.length;i++){ out.push(a.splice(Math.floor(r()*a.length),1)[0]); } return out; }
    function cap(s){ return s.charAt(0).toUpperCase()+s.slice(1) }
    function id(prefix, i){ return `${prefix}-${String(i).padStart(3,'0')}` }

    function syllableName(r, tone){
      const syll = {
        classic:["an","ar","bel","ca","da","el","fa","gal","har","is","jor","kel","lir","mor","nel","or","pa","qua","rhi","sa","tor","ur","val","wen","xel","yor","zel"],
        dark:["mor","noir","gloam","umb","dol","grav","nek","var","zul","vex","drei","thar","khar","wyrm"],
        steampunk:["gear","steam","bolt","cog","brass","tink","pneu","copper","fuse","riv","spindle"],
        myth:["aeg","od","ish","ra","zeph","io","sol","lun","tyr","fre","eir","hel"],
        sci:["neo","ion","quant","cyber","astra","plasma","proto","omega","nova","phase","flux"]
      };
      const pool = (syll[tone]||[]).concat(syll.classic);
      const len = 2 + Math.floor(r()*2);
      let s=""; for(let i=0;i<len;i++) s+= choice(r,pool);
      return cap(s);
    }

    /* =========================
     *  テンプレ/語彙
     * ========================= */
    const LEX = {
      roles: ["ギルドマスター","考古学者","巡回騎士","密偵","占星術師","錬金術師","旅の商人","巫女","司書","鍛冶師","船乗り","薬師","狩人","吟遊詩人","修道士"],
      traits: ["勇敢","狡猾","博識","短気","誠実","猜疑心が強い","陽気","冷静","計算高い","臆病","義理堅い","野心家"],
      factions: ["碧星同盟","砂冠商会","螺旋教団","古図書騎士団","白霧旅団","錆鉄工房","風詠み集落","赤砂盗賊団"],
      biomes: ["砂漠","湿原","黒森","高地","沿岸","雪原","火山地帯","古代都市跡"],
      itemTypes: ["剣","短剣","槍","杖","弓","護符","指輪","書","設計図","薬","鉱石","布","レンズ","コイル"],
      rarities: ["Common","Uncommon","Rare","Epic","Legendary"],
      verbs: ["救出せよ","護衛せよ","探索せよ","奪還せよ","調査せよ","討伐せよ","修復せよ","封印せよ","交渉せよ","護送せよ","潜入せよ"],
      twists: ["依頼主は真犯人","実は時間制限あり","二重スパイがいる","偽物が混じっている","古き呪いが再発","天候異常が発生","儀式の日が前倒し"],
      rewardsExtra: ["評判+10","ギルドランク昇格","隠し店舗の解放","旅人の加護","快速移動の解放"]
    };

    const DIFF_MULT = { easy: 0.8, normal: 1.0, hard: 1.3, epic: 1.7 };

    /* =========================
     *  生成器
     * ========================= */
    function genFactions(r, themes){
      const count = Math.min(5, 2 + Math.floor(r()*4));
      return Array.from({length:count}, (_,i)=>({ id: id('F',i+1), name: `${choice(r,LEX.factions)}`, goal: `${choice(r,["遺物の独占","古文書の解読","交易路の掌握","禁術の復活","辺境防衛"])}`, vibe: choice(r,["協調的","中立","敵対的"]) }));
    }

    function genLocations(r, themes){
      const count = Math.min(8, 4 + Math.floor(r()*5));
      return Array.from({length:count}, (_,i)=>({ id: id('L',i+1), name: `${choice(r,LEX.biomes)}の${syllableName(r,'classic')}`, feature: choice(r,["崩れた門","封じ石","光る碑文","隠し水路","浮遊足場","古代機構"]) }));
    }

    function genNPCs(r, tone, factions){
      const count = Math.min(12, 6 + Math.floor(r()*6));
      return Array.from({length:count}, (_,i)=>{
        const fac = choice(r, factions);
        return {
          id: id('N',i+1),
          name: syllableName(r,tone),
          role: choice(r, LEX.roles),
          trait: choice(r, LEX.traits),
          faction: fac?.id || null
        }
      });
    }

    function genItems(r, tone){
      const count = Math.min(18, 8 + Math.floor(r()*10));
      return Array.from({length:count}, (_,i)=>{
        const t = choice(r, LEX.itemTypes);
        const rare = choice(r, LEX.rarities);
        return {
          id: id('I',i+1),
          name: `${syllableName(r,tone)}の${t}`,
          type: t,
          rarity: rare,
          value: Math.floor((10+ r()*90) * (1 + 0.3*LEX.rarities.indexOf(rare)))
        }
      });
    }

    function genQuests(r, tone, count, npcs, locations, items, difficulty){
      const q = [];
      const scale = DIFF_MULT[difficulty] || 1.0;
      for(let i=0;i<count;i++){
        const giver = choice(r, npcs);
        const loc = choice(r, locations);
        const verb = choice(r, LEX.verbs);
        const keyItem = choice(r, items);
        const level = Math.max(1, Math.round((i+1)*scale + r()*3));
        const objectives = [
          `${loc.name}で手掛かりを見つける`,
          `${giver.name}(${giver.role})に報告する`,
          `${keyItem.name}を入手する`
        ];
        // 依存関係:稀に前のクエストを前提にする
        let dependsOn = null;
        if(i>0 && r()<0.4){ dependsOn = q[Math.floor(r()*i)].id; }
        // ツイストは低確率で
        const twist = r()<0.35 ? choice(r, LEX.twists) : null;
        const rewardGold = Math.floor((100+ r()*200) * scale * (1 + i*0.05));
        const rewardItems = pickN(r, items, r()<0.6?1:2).map(o=>o.id);
        q.push({
          id: id('Q',i+1),
          title: `${verb}:${loc.name}`,
          level,
          giver: giver.id,
          location: loc.id,
          objectives,
          requires: dependsOn,
          reward: { gold: rewardGold, items: rewardItems, extra: r()<0.25? choice(r, LEX.rewardsExtra): null },
          twist
        });
      }
      return q;
    }

    function assembleWorld(input){
      const seed = input.seed || `${Date.now().toString(36)}-${cyrb128(input.worldName + (input.themes||''))}`;
      const r = rngFromSeed(seed);
      const tone = input.tone || 'classic';
      const factions = genFactions(r, input.themes);
      const locations = genLocations(r, input.themes);
      const npcs = genNPCs(r, tone, factions);
      const items = genItems(r, tone);
      const quests = genQuests(r, tone, input.questCount, npcs, locations, items, input.difficulty);
      return { meta: { seed, createdAt: new Date().toISOString(), worldName: input.worldName||syllableName(r,tone), themes: input.themes, difficulty: input.difficulty, tone }, factions, locations, npcs, items, quests };
    }

    /* =========================
     *  出力レンダリング
     * ========================= */
    function renderText(world){
      const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
      const NPC = idmap(world.npcs);
      const LOC = idmap(world.locations);
      const ITM = idmap(world.items);

      const lines = [];
      lines.push(`# 世界:${world.meta.worldName}`);
      lines.push(`- テーマ:${world.meta.themes||'—'} / トーン:${world.meta.tone} / 難易度:${world.meta.difficulty}`);
      lines.push(`- 生成日時:${new Date(world.meta.createdAt).toLocaleString()}`);
      lines.push(`- シード:${world.meta.seed}`);
      lines.push(`\n## 勢力(${world.factions.length})`);
      world.factions.forEach(f=>{ lines.push(`- [${f.id}] ${f.name}|目的:${f.goal}|態度:${f.vibe}`) });
      lines.push(`\n## 場所(${world.locations.length})`);
      world.locations.forEach(l=>{ lines.push(`- [${l.id}] ${l.name}|特徴:${l.feature}`) });
      lines.push(`\n## NPC(${world.npcs.length})`);
      world.npcs.forEach(n=>{ lines.push(`- [${n.id}] ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}`) });
      lines.push(`\n## アイテム(${world.items.length})`);
      world.items.forEach(i=>{ lines.push(`- [${i.id}] ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}`) });
      lines.push(`\n## クエスト(${world.quests.length})`);
      world.quests.forEach(q=>{
        const giver = NPC[q.giver]?.name || q.giver;
        const loc = LOC[q.location]?.name || q.location;
        const req = q.requires? `(前提:${q.requires})` : '';
        lines.push(`\n### [${q.id}] ${q.title} Lv.${q.level} ${req}`);
        lines.push(`- 依頼主:${giver}`);
        lines.push(`- 場所:${loc}`);
        lines.push(`- 目的:`);
        q.objectives.forEach(o=>lines.push(`  - ${o}`));
        const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
        lines.push(`- 報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}`);
        if(q.twist) lines.push(`- ツイスト:${q.twist}`);
      });
      return lines.join('\n');
    }

    function renderHTML(world){
      const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
      const NPC = idmap(world.npcs);
      const LOC = idmap(world.locations);
      const ITM = idmap(world.items);

      const head = `
        <h1>世界:${world.meta.worldName}</h1>
        <div class="kv text-sm text-slate-600 gap-x-2">
          <span class="badge">トーン:${world.meta.tone}</span>
          <span class="badge">難易度:${world.meta.difficulty}</span>
          <span class="badge">クエスト:${world.quests.length}</span>
          <span class="badge">シード:${world.meta.seed}</span>
        </div>
        <p class="mt-2 text-sm text-slate-600">テーマ:${world.meta.themes||'—'} / 生成日時:${new Date(world.meta.createdAt).toLocaleString()}</p>
      `;

      const factions = `
        <h2>勢力(${world.factions.length})</h2>
        <ul>
          ${world.factions.map(f=>`<li><code>[${f.id}]</code> ${f.name}|目的:${f.goal}|態度:${f.vibe}</li>`).join('')}
        </ul>
      `;

      const locs = `
        <h2>場所(${world.locations.length})</h2>
        <ul>
          ${world.locations.map(l=>`<li><code>[${l.id}]</code> ${l.name}|特徴:${l.feature}</li>`).join('')}
        </ul>
      `;

      const npcs = `
        <h2>NPC(${world.npcs.length})</h2>
        <ul>
          ${world.npcs.map(n=>`<li><code>[${n.id}]</code> ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}</li>`).join('')}
        </ul>
      `;

      const items = `
        <h2>アイテム(${world.items.length})</h2>
        <ul>
          ${world.items.map(i=>`<li><code>[${i.id}]</code> ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}</li>`).join('')}
        </ul>
      `;

      const quests = `
        <h2>クエスト(${world.quests.length})</h2>
        ${world.quests.map(q=>{
          const giver = NPC[q.giver]?.name || q.giver;
          const loc = LOC[q.location]?.name || q.location;
          const req = q.requires? `(前提:${q.requires})` : '';
          const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
          return `
            <details class="quest">
              <summary><strong><code>[${q.id}]</code> ${q.title}</strong> <span class="text-sm text-slate-600">Lv.${q.level} ${req}</span></summary>
              <div class="mt-2 text-sm">
                <div>依頼主:${giver}</div>
                <div>場所:${loc}</div>
                <div class="mt-1">目的:</div>
                <ul>
                  ${q.objectives.map(o=>`<li>${o}</li>`).join('')}
                </ul>
                <div class="mt-1">報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}</div>
                ${q.twist? `<div class="mt-1 text-rose-700">ツイスト:${q.twist}</div>`:''}
              </div>
            </details>`;
        }).join('')}
      `;

      return [head, factions, locs, npcs, items, quests].join('');
    }

    function renderCards(world){
      const $cards = document.getElementById('cards');
      $cards.innerHTML = '';
      const make = (title, body)=>{
        const el = document.createElement('div');
        el.className = 'rounded-2xl border p-4 bg-white';
        el.innerHTML = `<div class="text-sm font-bold mb-2">${title}</div><div class="text-xs text-slate-700 whitespace-pre-wrap">${body}</div>`;
        $cards.appendChild(el);
      };
      make('ワールド', `名前:${world.meta.worldName}\n難易度:${world.meta.difficulty}\nトーン:${world.meta.tone}\nシード:${world.meta.seed}`);
      make('勢力', world.factions.map(f=>`[${f.id}] ${f.name}/目的:${f.goal}`).join('\n'));
      make('場所', world.locations.map(l=>`[${l.id}] ${l.name}/${l.feature}`).join('\n'));
      make('NPC', world.npcs.slice(0,12).map(n=>`[${n.id}] ${n.name}/${n.role}`).join('\n'));
      make('アイテム', world.items.slice(0,15).map(i=>`[${i.id}] ${i.name}/${i.rarity}`).join('\n'));
      make('クエスト', world.quests.map(q=>`[${q.id}] ${q.title} Lv.${q.level}${q.requires? '(前提:'+q.requires+')':''}`).join('\n'));
    }

    /* =========================
     *  CSV/JSON/コピー/保存
     * ========================= */
    function toCSV(rows){
      return rows.map(r=> r.map(v=>`"${String(v).replaceAll('"','""')}"`).join(',')).join('\n');
    }
    function exportCSVs(world){
      const npcRows = [["id","name","role","trait","faction"]].concat(world.npcs.map(n=>[n.id,n.name,n.role,n.trait,n.faction||'']));
      const itemRows = [["id","name","type","rarity","value"]].concat(world.items.map(i=>[i.id,i.name,i.type,i.rarity,i.value]));
      const questRows = [["id","title","level","giver","location","requires","objectives","reward_gold","reward_items","twist"]].concat(
        world.quests.map(q=>[
          q.id, q.title, q.level, q.giver, q.location, q.requires||'', q.objectives.join(' / '), q.reward.gold, q.reward.items.join('|'), q.twist||''
        ])
      );
      const files = [
        {name:`${world.meta.worldName}_NPC.csv`, data: toCSV(npcRows)},
        {name:`${world.meta.worldName}_Items.csv`, data: toCSV(itemRows)},
        {name:`${world.meta.worldName}_Quests.csv`, data: toCSV(questRows)}
      ];
      files.forEach(f=>{
        const blob = new Blob(["\ufeff"+f.data], {type:'text/csv'});
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = f.name; a.click(); URL.revokeObjectURL(a.href);
      });
    }
    function downloadJSON(world){
      const blob = new Blob([JSON.stringify(world, null, 2)], {type:'application/json'});
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = `${world.meta.worldName}_world.json`; a.click(); URL.revokeObjectURL(a.href);
    }
    function copyText(text){
      navigator.clipboard.writeText(text).then(()=>{
        toast('テキストをコピーしました');
      });
    }
    function saveLocal(world){ localStorage.setItem('quest_foundry_last', JSON.stringify(world)); toast('保存しました'); }
    function loadLocal(){ const s=localStorage.getItem('quest_foundry_last'); if(!s){ toast('保存データなし'); return null; } try{ return JSON.parse(s);}catch(e){ toast('読込失敗'); return null; } }

    /* =========================
     *  UI
     * ========================= */
    function toast(msg){
      const t = document.createElement('div');
      t.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-sm px-4 py-2 rounded-xl shadow-lg';
      t.textContent = msg; document.body.appendChild(t);
      setTimeout(()=>{ t.classList.add('opacity-0'); t.style.transition='opacity .6s'; }, 1600);
      setTimeout(()=> t.remove(), 2300);
    }

    let lastInput = null;
    let lastWorld = null;

    function currentInput(){
      const worldName = document.getElementById('worldName').value.trim();
      const themes = document.getElementById('themes').value.trim();
      const difficulty = document.getElementById('difficulty').value;
      const questCount = Math.max(1, Math.min(30, parseInt(document.getElementById('questCount').value || '8')));
      const seed = document.getElementById('seed').value.trim();
      const tone = document.getElementById('tone').value;
      return { worldName, themes, difficulty, questCount, seed, tone };
    }

    function applyWorld(world){
      lastWorld = world;
      document.getElementById('meta').textContent = `ワールド:${world.meta.worldName} / クエスト:${world.quests.length}件`;
      document.getElementById('outText').innerHTML = renderHTML(world);
      document.getElementById('outJSON').textContent = JSON.stringify(world, null, 2);
      renderCards(world);
    }

    function generate(withNewSeed=false){
      const input = currentInput();
      if(withNewSeed && !document.getElementById('lockSeed').checked){ input.seed = ''; }
      if(!input.seed) { input.seed = cyrb128((input.worldName||'World') + (input.themes||'') + Date.now()); document.getElementById('seed').value = input.seed; }
      lastInput = input;
      const world = assembleWorld(input);
      applyWorld(world);
    }

    // イベント
    document.getElementById('btnGenerate').addEventListener('click', ()=> generate(false));
    document.getElementById('btnRegenerate').addEventListener('click', ()=> generate(false));
    document.getElementById('btnShuffleSeed').addEventListener('click', ()=> generate(true));
    document.getElementById('btnCopyText').addEventListener('click', ()=>{ if(lastWorld) copyText(renderText(lastWorld)); });
    document.getElementById('btnDownloadJSON').addEventListener('click', ()=>{ if(lastWorld) downloadJSON(lastWorld); });
    document.getElementById('btnExportCSV').addEventListener('click', ()=>{ if(lastWorld) exportCSVs(lastWorld); });
    document.getElementById('btnToggleJson').addEventListener('click', ()=>{ document.getElementById('jsonBlock').classList.toggle('hidden'); });
    document.getElementById('btnSave').addEventListener('click', ()=>{ if(lastWorld) saveLocal(lastWorld); });
    document.getElementById('btnLoad').addEventListener('click', ()=>{ const w=loadLocal(); if(w) applyWorld(w); });
    document.getElementById('btnPrint').addEventListener('click', ()=> window.print());

    // 初期プレースホルダ生成
    window.addEventListener('DOMContentLoaded', ()=>{
      document.getElementById('worldName').value = '運命の剣界';
      document.getElementById('themes').value = '古代遺跡 風の精霊 砂漠 旅人ギルド';
      generate(true);
    });
  </script>
</body>
</html>

AIで死んだ人を蘇らせる方法

結論:医学的・宗教的な意味での「蘇生」はAIには不可能です。
ただし、生前の話し方・価値観・記憶を“再現する対話エージェント(面影AI)”をつくることはできます。ここでは、実装寄りの「方法」を段階別にまとめます。

前提(とても大事)

  • 法と同意:故人の遺志・遺族の合意・著作権/肖像権を確認。音声の複製は特に慎重に。
  • 限界の明示:「これはAIの再現であり推測を含む」「本人そのものではない」とUIで常時表示。
  • メンタルケア:使い過ぎないスイッチ、利用時間上限、ヘルプ先の表示。

3つの実装レベル

①最小構成(テキスト対話のみ:1–2日で可)

  1. 材料集め:日記、手紙、SNS、メール、スピーチ、作品など(TXT/MD/PDFで保存)。
  2. 人格プロファイル(YAMLなどで1ファイル) name: 〇〇 tone: おだやかで前向き/「〜かな」「まず試そう」が口癖 loves: 散歩, 写真, 苦いコーヒー avoid: 政治助言, 金融助言, 医療判断 boundaries: 「私はAIの再現です」を毎回明示
  3. RAG構成(検索+要約+回答)
    • 文章を分割→ベクトル化→ローカルDB(FAISS等)に格納
    • 会話のたびに関連メモを上位k件取り出し、根拠として引用
  4. 初期プロンプト(骨子) あなたは故人〇〇さんの面影AIです。上記プロファイルを尊重し、 「私はAIの再現で、推測を含みます」と毎回明示。根拠はメモから引用。 わからないときは推測しない。
  5. 出力ルール:最初の一文で必ず 私は〇〇さんの“面影”を再現したAIです(本人ではありません)。

②声・見た目付き(3–7日)

  1. 音声:本人の公開音源は原則NG(同意必須)。似た声質の近似TTSを採用。
  2. アバター:写真をそのまま3D化は避け、**スタイル化(似顔絵/イラスト調)**で距離を置く。
  3. 感情の安全装置:トリガーワードで休止・専門窓口案内。夜間は通知/起動制限。

③高度(思い出検索・年代タイムライン)

  1. タイムラインDB/memories/{年}/{月日_タイトル}.md構造で年代検索。
  2. トピック分類:趣味・家族・仕事・旅などタグ付け → 会話で「2009年の旅行の話」など即参照。
  3. 写真・動画の“説明文”生成:画像にキャプションと撮影年のメタデータを付けて検索性向上。

最小サンプル(疑似コードの流れ)

ユーザー入力 → 重要語抽出 → ベクトル検索で関連メモk件取得
→ 回答プロンプトに {人格プロファイル + 関連メモ引用 + ガードレール} を結合
→ LLMで文章生成 → 「面影AIの明示」+ 根拠セクションを付けて返す

データ設計の雛形

  • /profile/profile.yaml(人格)
  • /memories/*.md(思い出テキスト)
  • /photos/*.(jpg|png) + /photos/meta.json(撮影年/場所/キャプション)
  • /policies/consent.md(同意/共有範囲/利用目的)
  • /guardrails/rules.md(答えない領域、緊急停止条件)

会話テンプレ(例)

ユーザー:「あのときの失敗、どう乗り越えた?」
AI(冒頭に明示):「私は〇〇さんの面影AIです(本人ではありません)。
—根拠—

  • 2013-05-20_転職メモ.md『まず小さく試し…』
    —回答—
    〇〇さんは“まず試す→振り返る→続ける”を重視していました。あなたの今回の件なら…」

ガードレール例(実装時に必須)

  • 医療・法律・投資は一般情報のみ、意思決定は専門家へ誘導
  • 故人の事実誤認や現存人物への断定的評価は拒否
  • 個人情報の外部共有はデフォルトOFF、暗号化保存

よくある落とし穴

  • 近親者ほど**「本人そのもの」感**を求めたくなる → 境界の文章化とUI明示で予防
  • 音声の権利問題 → “似ているが別人”のTTSを採用
  • データの偏り → 複数の視点から材料を集め、脚注で出典表示

必要なら、あなたの手元の素材量に合わせて

  • プロファイルYAML雛形
  • フォルダ構成(そのまま使える)
  • 初期プロンプト(日本語)
  • ガードレール文面 & 同意テンプレ
    を今ここで作成します。どのレベル(①/②/③)から始めたいですか?

この回答がよい

自分

最近感じたのがSora2で安倍さんの動画生成できるようになって本格的に死んだ人を蘇らせていけるような気がする

ホロライブAIプロンプトサイト.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>ホロライブ AIプロンプト生成サイト</title>
  <meta name="description" content="ホロライブのメンバー向けに、画像生成AIで使えるプロンプトをワンクリック作成。日英対応/テンプレ/ネガティブ/コピーボタン/履歴保存。" />
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    tailwind.config = {
      theme: {
        extend: {
          fontFamily: { sans: ['Noto Sans JP', 'ui-sans-serif', 'system-ui'] },
          colors: {
            skin: {
              base: '#0b1020',
              card: '#0f152b',
              accent: '#60a5fa',
              soft: '#a5b4fc'
            }
          },
          boxShadow: {
            glass: '0 8px 30px rgba(0,0,0,.35)'
          }
        }
      }
    }
  </script>
  <style>
    html,body{height:100%}
    .glass{backdrop-filter: saturate(140%) blur(12px); background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));}
    .chip{border:1px solid rgba(255,255,255,.15);}
    .mono{font-feature-settings: "ss01" on, "cv01" on;}
  </style>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-skin-base to-black text-slate-100 font-sans">
  <header class="sticky top-0 z-30 border-b border-white/10 bg-slate-900/70 glass">
    <div class="max-w-6xl mx-auto px-4 py-4 flex items-center gap-4">
      <div class="size-10 rounded-2xl bg-skin-accent/20 grid place-items-center shadow-glass">
        <span class="text-skin-accent font-black">AI</span>
      </div>
      <div>
        <h1 class="text-xl md:text-2xl font-extrabold tracking-tight">ホロライブ AIプロンプト生成</h1>
        <p class="text-slate-300 text-sm">メンバーを選ぶ→テンプレを選ぶ→生成! 日/英・ネガティブ・履歴・コピペ完備</p>
      </div>
      <div class="ms-auto flex items-center gap-3">
        <label class="flex items-center gap-2 text-sm"><input id="langToggle" type="checkbox" class="accent-skin-accent"> 英語で出力</label>
        <button id="randomBtn" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10">ランダム</button>
        <button id="resetBtn" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10">リセット</button>
      </div>
    </div>
  </header>

  <main class="max-w-6xl mx-auto px-4 py-6 grid lg:grid-cols-2 gap-6">
    <!-- 左:入力パネル -->
    <section class="glass rounded-2xl p-5 shadow-glass border border-white/10">
      <h2 class="font-bold text-lg mb-3">1) メンバー & スタイル設定</h2>
      <div class="grid md:grid-cols-2 gap-4">
        <div>
          <label class="block text-sm mb-1">メンバー</label>
          <div class="flex gap-2">
            <input id="memberSearch" type="text" placeholder="名前/世代/特徴で検索" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
          </div>
          <div class="mt-2 max-h-48 overflow-auto pr-1">
            <ul id="memberList" class="space-y-1"></ul>
          </div>
        </div>
        <div>
          <label class="block text-sm mb-1">テンプレート (用途)</label>
          <select id="templateSelect" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
            <option value="portrait">高品質ポートレート</option>
            <option value="fullbody">全身イラスト</option>
            <option value="chibi">デフォルメ/ちびキャラ</option>
            <option value="vtuber">VTuber配信サムネ</option>
            <option value="live2d">Live2D立ち絵</option>
            <option value="vrchat">VRChat アバター風</option>
            <option value="manga">モノクロ漫画コマ</option>
            <option value="poster">キービジュアル/ポスター</option>
            <option value="landscape">背景&小さめ人物</option>
          </select>
          <div class="grid grid-cols-2 gap-2 mt-3">
            <label class="text-sm flex items-center gap-2"><input id="nsfwSafe" type="checkbox" checked class="accent-skin-accent"> NSFW禁止</label>
            <label class="text-sm flex items-center gap-2"><input id="useNeg" type="checkbox" checked class="accent-skin-accent"> ネガティブ付与</label>
            <label class="text-sm flex items-center gap-2"><input id="addPose" type="checkbox" class="accent-skin-accent"> ポーズ指定</label>
            <label class="text-sm flex items-center gap-2"><input id="addCamera" type="checkbox" class="accent-skin-accent"> カメラ/レンズ</label>
          </div>
        </div>
      </div>

      <div class="grid md:grid-cols-3 gap-4 mt-5">
        <div>
          <label class="block text-sm mb-1">画風プリセット</label>
          <select id="stylePreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
            <option value="anime">アニメ塗り(鮮やか)</option>
            <option value="semiReal">セミリアル</option>
            <option value="watercolor">水彩/やわらか</option>
            <option value="celshade">セルルック</option>
            <option value="painterly">厚塗り/絵画風</option>
            <option value="3dtoon">3Dトゥーン</option>
          </select>
        </div>
        <div>
          <label class="block text-sm mb-1">照明/雰囲気</label>
          <select id="moodPreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
            <option value="soft">柔らかい光 / やさしい雰囲気</option>
            <option value="dramatic">ドラマチック / リムライト</option>
            <option value="studio">スタジオ照明 / クリーン</option>
            <option value="sunset">夕焼け / ゴールデンアワー</option>
            <option value="night">夜景 / ネオン</option>
          </select>
        </div>
        <div>
          <label class="block text-sm mb-1">解像度・比率</label>
          <select id="aspectPreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
            <option value="square">正方形(1024×1024)</option>
            <option value="portrait">縦長(768×1152)</option>
            <option value="landscape">横長(1152×768)</option>
            <option value="thumb">サムネ(1280×720)</option>
          </select>
        </div>
      </div>

      <div class="mt-5 grid md:grid-cols-2 gap-4">
        <div>
          <label class="block text-sm mb-1">衣装・小物(任意)</label>
          <input id="outfitInput" type="text" placeholder="例: 制服, ライブ衣装, 私服, 王冠, マント" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
        </div>
        <div>
          <label class="block text-sm mb-1">背景・シーン(任意)</label>
          <input id="bgInput" type="text" placeholder="例: ステージ, 星空, 教室, サイバーシティ" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
        </div>
      </div>

      <div class="mt-5 grid md:grid-cols-2 gap-4">
        <div>
          <label class="block text-sm mb-1">モデル/LoRA(任意)</label>
          <input id="modelInput" type="text" placeholder="例: anime-v4, AnythingV5, holo_member_lora:0.8" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
        </div>
        <div>
          <label class="block text-sm mb-1">追加キーワード(任意)</label>
          <input id="extraInput" type="text" placeholder="例: 1girl, detailed eyes, dynamic lighting" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
        </div>
      </div>

      <div class="mt-6 flex flex-wrap gap-3">
        <button id="generateBtn" class="px-5 py-3 rounded-2xl bg-skin-accent text-black font-bold hover:opacity-90">生成</button>
        <button id="copyBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">コピー</button>
        <button id="saveBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">履歴に保存</button>
        <button id="exportBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">JSON書き出し</button>
        <button id="importBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">JSON読込</button>
      </div>
    </section>

    <!-- 右:出力/履歴 -->
    <section class="space-y-6">
      <div class="glass rounded-2xl p-5 shadow-glass border border-white/10">
        <h2 class="font-bold text-lg">2) 出力(Positive / Negative / メタ)</h2>
        <div class="mt-3 grid gap-3">
          <label class="text-sm">Positive Prompt</label>
          <textarea id="posOut" rows="6" class="mono w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" placeholder="ここに生成結果"></textarea>
          <label class="text-sm">Negative Prompt</label>
          <textarea id="negOut" rows="4" class="mono w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" placeholder="ここにネガティブ"></textarea>
          <div class="grid md:grid-cols-3 gap-3">
            <div>
              <label class="text-sm">解像度</label>
              <input id="metaRes" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" readonly>
            </div>
            <div>
              <label class="text-sm">推奨CFG/Steps</label>
              <input id="metaCfg" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" value="CFG 6-8 / Steps 28-36">
            </div>
            <div>
              <label class="text-sm">推奨Sampler</label>
              <input id="metaSampler" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" value="DPM++ 2M Karras">
            </div>
          </div>
        </div>
      </div>

      <div class="glass rounded-2xl p-5 shadow-glass border border-white/10">
        <div class="flex items-center gap-3 mb-3">
          <h2 class="font-bold text-lg">3) 履歴</h2>
          <button id="clearHist" class="ms-auto px-3 py-1.5 text-sm rounded-xl bg-white/10 border border-white/10 hover:bg-white/20">全削除</button>
        </div>
        <div id="history" class="space-y-3 max-h-72 overflow-auto pr-1"></div>
      </div>
    </section>
  </main>

  <footer class="max-w-6xl mx-auto px-4 pb-10 text-slate-400 text-sm">
    <div class="glass rounded-2xl p-4 border border-white/10">
      <p class="leading-relaxed">注意:本ツールは各メンバーの公式ガイドラインを尊重し、成人向けや誹謗中傷の内容を禁止します。商用利用や二次創作ルールは各社ポリシーをご確認ください。</p>
    </div>
  </footer>

  <script>
    // --- データセット(抜粋・追加可) ---
    const MEMBERS = [
      // JP
      { key:'Tokino Sora', gen:'JP0', color:'#5bc0eb', motifs:['星','リボン'], traits:['清楚','やさしい'], outfit:['セーラー風','リボン'], keywords:['idol','first gen','blue ribbon'], en:true },
      { key:'Shirakami Fubuki', gen:'JP1', color:'#ffffff', motifs:['狐','尻尾'], traits:['元気','明るい'], outfit:['セーラー','マフラー'], keywords:['fox girl','white hair','animal ears'] },
      { key:'Natsuiro Matsuri', gen:'JP1', color:'#f4a261', motifs:['祭','ポニーテール'], traits:['活発','いたずら'], outfit:['体操服','浴衣'], keywords:['cheerful','ponytail'] },
      { key:'Minato Aqua', gen:'JP2', color:'#b388ff', motifs:['メイド','ヘッドドレス'], traits:['ドジっ子','ピンク紫髪'], outfit:['メイド服'], keywords:['maid','twin tails'] },
      { key:'Shion', gen:'JP2', color:'#c084fc', motifs:['魔法','三角帽'], traits:['小悪魔','ツリ目'], outfit:['魔女服'], keywords:['witch','purple hair'] },
      { key:'Nakiri Ayame', gen:'JP2', color:'#ef4444', motifs:['鬼角','和装'], traits:['クール','凛'], outfit:['巫女風'], keywords:['oni horns','kimono style'] },
      { key:'Ookami Mio', gen:'GAMERS', color:'#111827', motifs:['狼','耳'], traits:['頼れる','落ち着き'], outfit:['黒衣装'], keywords:['wolf girl','black outfit'] },
      { key:'Houshou Marine', gen:'JP3', color:'#ef4444', motifs:['海賊帽','錨'], traits:['情熱','大人っぽい'], outfit:['海賊衣装'], keywords:['pirate','captain hat'] },
      { key:'Usada Pekora', gen:'JP3', color:'#93c5fd', motifs:['うさ耳','人参'], traits:['やんちゃ','元気'], outfit:['うさぎパーカー'], keywords:['bunny ears','carrot'] },
      { key:'Shiranui Flare', gen:'JP3', color:'#f59e0b', motifs:['エルフ','耳'], traits:['包容','陽気'], outfit:['冒険者'], keywords:['elf ears','adventurer'] },
      { key:'Shirogane Noel', gen:'JP3', color:'#9ca3af', motifs:['騎士','鎧'], traits:['真面目','力持ち'], outfit:['鎧','マント'], keywords:['knight armor','silver hair'] },
      { key:'Hoshimachi Suisei', gen:'INoNaka/JP', color:'#60a5fa', motifs:['星','アイドル'], traits:['クール','アイドル'], outfit:['青系衣装'], keywords:['star motif','blue idol'] },
      { key:'Amane Kanata', gen:'JP4', color:'#60a5fa', motifs:['天使','羽'], traits:['ストイック'], outfit:['白蒼衣装'], keywords:['angel wings','halo'] },
      { key:'Kiryu Coco', gen:'JP4', color:'#f97316', motifs:['ドラゴン','角'], traits:['豪快'], outfit:['ドラゴンモチーフ'], keywords:['dragon horns','orange hair'] },
      { key:'Tsunomaki Watame', gen:'JP4', color:'#facc15', motifs:['羊','リボン'], traits:['ふわふわ'], outfit:['羊モチーフ'], keywords:['sheep girl','blonde'] },
      { key:'Himemori Luna', gen:'JP4', color:'#f472b6', motifs:['姫','王冠'], traits:['キュート'], outfit:['姫ドレス'], keywords:['princess crown','pink'] },
      { key:'Laplus Darknesss', gen:'HoloX', color:'#6d28d9', motifs:['悪魔','マント'], traits:['いたずら'], outfit:['黒紫コート'], keywords:['devilish','hooded coat'] },
      { key:'Takane Lui', gen:'HoloX', color:'#ef4444', motifs:['スパイ','赤黒'], traits:['クール'], outfit:['スーツ風'], keywords:['spy','red black'] },
      { key:'Sakamata Chloe', gen:'HoloX', color:'#94a3b8', motifs:['シャチ','フード'], traits:['あざとい'], outfit:['白黒フード'], keywords:['orca hoodie','monochrome'] },
      { key:'Hakui Koyori', gen:'HoloX', color:'#fb7185', motifs:['研究','ピンク'], traits:['好奇心'], outfit:['研究白衣'], keywords:['lab coat','pink hair'] },
      { key:'Kazama Iroha', gen:'HoloX', color:'#86efac', motifs:['忍者','刀'], traits:['素直'], outfit:['忍装束'], keywords:['ninja','katana'] },
      // EN
      { key:'Mori Calliope', gen:'EN Myth', color:'#ef4444', motifs:['鎌','死神'], traits:['クール'], outfit:['黒×ピンク'], keywords:['reaper scythe','rapper'] },
      { key:'Gawr Gura', gen:'EN Myth', color:'#60a5fa', motifs:['サメ','フード'], traits:['いたずら'], outfit:['サメパーカー'], keywords:['shark hoodie','trident'] },
      { key:'Takanashi Kiara', gen:'EN Myth', color:'#fb923c', motifs:['鳥','オレンジ'], traits:['情熱'], outfit:['アイドル衣装'], keywords:['phoenix','orange hair'] },
      { key:"Ninomae Ina'nis", gen:'EN Myth', color:'#a78bfa', motifs:['触手','本'], traits:['穏やか'], outfit:['修道服風'], keywords:['tentacle motif','violet'] },  <!-- ★ 修正済み:ダブルクォート -->
      { key:'Amelia Watson', gen:'EN Myth', color:'#fbbf24', motifs:['探偵','時計'], traits:['好奇心'], outfit:['探偵コート'], keywords:['detective','magnifying glass'] },
      { key:'Hakos Baelz', gen:'EN Council', color:'#ef4444', motifs:['ネズミ','カオス'], traits:['ハイテンション'], outfit:['赤系衣装'], keywords:['chaos','rat tail'] },
      { key:'IRyS', gen:'EN Project:Hope', color:'#ef5fff', motifs:['天使悪魔','クリスタル'], traits:['希望'], outfit:['黒×赤×紫'], keywords:['nephilim','crystal'] },
      // ID (抜粋)
      { key:'Kobo Kanaeru', gen:'ID3', color:'#60a5fa', motifs:['雨','水'], traits:['やんちゃ'], outfit:['青系パーカー'], keywords:['rain theme','blue hair'] },
    ];

    // ネガティブテンプレ
    const NEGATIVE = 'nsfw, nude, lowres, low quality, worst quality, extra fingers, deformed hands, poorly drawn, watermark, logo, signature, text, blurry, jpeg artifacts, bad anatomy, out of frame';

    // UI取得
    const memberSearch = document.getElementById('memberSearch');
    const memberList = document.getElementById('memberList');
    const templateSelect = document.getElementById('templateSelect');
    const stylePreset = document.getElementById('stylePreset');
    const moodPreset = document.getElementById('moodPreset');
    const aspectPreset = document.getElementById('aspectPreset');
    const outfitInput = document.getElementById('outfitInput');
    const bgInput = document.getElementById('bgInput');
    const modelInput = document.getElementById('modelInput');
    const extraInput = document.getElementById('extraInput');
    const langToggle = document.getElementById('langToggle');
    const nsfwSafe = document.getElementById('nsfwSafe');
    const useNeg = document.getElementById('useNeg');
    const addPose = document.getElementById('addPose');
    const addCamera = document.getElementById('addCamera');

    const generateBtn = document.getElementById('generateBtn');
    const copyBtn = document.getElementById('copyBtn');
    const saveBtn = document.getElementById('saveBtn');
    const exportBtn = document.getElementById('exportBtn');
    const importBtn = document.getElementById('importBtn');
    const randomBtn = document.getElementById('randomBtn');
    const resetBtn = document.getElementById('resetBtn');

    const posOut = document.getElementById('posOut');
    const negOut = document.getElementById('negOut');
    const metaRes = document.getElementById('metaRes');
    const metaCfg = document.getElementById('metaCfg');
    const metaSampler = document.getElementById('metaSampler');
    const history = document.getElementById('history');
    const clearHist = document.getElementById('clearHist');

    // 状態
    let selectedMember = null;

    // 初期描画
    function renderMembers(filter=''){
      const f = filter.toLowerCase().trim();
      memberList.innerHTML = '';
      MEMBERS.filter(m=>{
        const s = [m.key, m.gen, ...(m.motifs||[]), ...(m.traits||[]), ...(m.keywords||[])].join(' ').toLowerCase();
        return !f || s.includes(f);
      }).forEach(m=>{
        const li = document.createElement('li');
        li.className = 'chip rounded-xl px-3 py-2 flex items-center gap-2 hover:bg-white/5 cursor-pointer';
        li.innerHTML = `<span class="inline-block size-3 rounded-full" style="background:${m.color}"></span><span class="font-medium">${m.key}</span><span class="text-xs text-slate-400">(${m.gen})</span>`;
        // クリック選択
        li.addEventListener('click',()=>{ selectedMember = m; highlightSelection(li); });
        // ★ キーボード対応
        li.setAttribute('tabindex', '0');
        li.setAttribute('role', 'button');
        li.addEventListener('keydown', (e) => {
          if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            selectedMember = m;
            highlightSelection(li);
          }
        });
        // 既選択のハイライト維持
        if (selectedMember && selectedMember.key === m.key) li.classList.add('bg-white/10');
        memberList.appendChild(li);
      })
    }

    function highlightSelection(activeLi){
      [...memberList.children].forEach(li=> li.classList.remove('bg-white/10'));
      activeLi.classList.add('bg-white/10');
    }

    memberSearch.addEventListener('input', e=> renderMembers(e.target.value));

    // アスペクト → 表示解像度
    function applyAspect(){
      const map = {
        square: '1024x1024',
        portrait: '768x1152',
        landscape: '1152x768',
        thumb: '1280x720'
      };
      metaRes.value = map[aspectPreset.value] || '1024x1024';
    }

    aspectPreset.addEventListener('change', applyAspect);
    applyAspect();

    // テンプレ文
    function templateText(id, name){
      const jp = {
        portrait: `${name}の高品質なバストアップポートレート, 視線はこちら, きらめく瞳, 細密な髪, 肌の質感,`,
        fullbody: `${name}の全身イラスト, ダイナミックなポーズ,`,
        chibi: `${name}のデフォルメちびキャラ, 等身2~3,`,
        vtuber: `${name}の配信サムネ風イラスト, サイドライト, 目を引くタイポの余白,`,
        live2d: `${name}のLive2D立ち絵, 胸上〜腰上, レイヤー分けしやすいシンプル背景,`,
        vrchat: `${name}のVRChatアバター風デザイン, 全身, セルルック,`,
        manga: `${name}が登場するモノクロ漫画コマ, スクリーントーン,`,
        poster: `${name}のキービジュアル, 迫力のある構図,`,
        landscape: `背景美術の中に小さく${name}, 遠景, 雰囲気重視,`
      };
      const en = {
        portrait: `high-quality bust portrait of ${name}, looking at viewer, sparkling eyes, detailed hair, skin texture,`,
        fullbody: `full-body illustration of ${name}, dynamic pose,`,
        chibi: `super-deformed chibi ${name}, 2~3 heads tall,`,
        vtuber: `stream thumbnail style illustration of ${name}, side lighting, space for bold typography,`,
        live2d: `Live2D standing illustration of ${name}, bust to waist-up, simple background for easy layer separation,`,
        vrchat: `VRChat avatar style design of ${name}, full body, toon shading,`,
        manga: `monochrome manga panel featuring ${name}, screen tones,`,
        poster: `key visual poster of ${name}, impactful composition,`,
        landscape: `cinematic background with small ${name} in scene, distant view, mood-focused,`
      }
      return (langToggle.checked? en : jp)[id] || '';
    }

    // スタイル・ムード
    function styleText(id){
      const jp = {
        anime: 'アニメ塗り, 高発色, クリアライン,',
        semiReal: 'セミリアル, 繊細なライティング, 細密質感,',
        watercolor: '水彩風, 柔らかい発色, にじみ,',
        celshade: 'セルシェーディング, シャープな影,',
        painterly: '厚塗り, 筆致, 奥行き,',
        threetoon: '3Dトゥーン, ノンフォトリアル,'
      };
      const en = {
        anime: 'anime coloring, vivid, clean linework,',
        semiReal: 'semi-realistic, delicate lighting, fine textures,',
        watercolor: 'watercolor style, soft colors, bleeding,',
        celshade: 'cel-shaded, sharp shadows,',
        painterly: 'painterly, visible brush strokes, depth,',
        threetoon: '3D toon, non-photorealistic,'
      };
      const key = id === '3dtoon' ? 'threetoon' : id;
      return (langToggle.checked? en : jp)[key] || '';
    }

    function moodText(id){
      const jp = {
        soft: 'やわらかい環境光, 穏やかな表情,',
        dramatic: 'ドラマチックライティング, リムライト, コントラスト強,',
        studio: 'スタジオ照明, 均一な光, 背景シンプル,',
        sunset: '夕焼けの光, ゴールデンアワー,',
        night: '夜景ネオン, グロー, 反射,'
      };
      const en = {
        soft: 'soft ambient light, gentle expression,',
        dramatic: 'dramatic lighting, rim light, high contrast,',
        studio: 'studio lighting, even illumination, simple background,',
        sunset: 'sunset glow, golden hour,',
        night: 'neon nightscape, glow, reflections,'
      };
      return (langToggle.checked? en : jp)[id] || '';
    }

    function poseText(){
      const jp = ['ピースサイン','片手を胸に','ほほえみ','ダンスポーズ','跳躍'];
      const en = ['peace sign','hand on chest','gentle smile','dance pose','jumping'];
      const arr = langToggle.checked? en : jp;
      return arr[Math.floor(Math.random()*arr.length)]
    }

    function cameraText(){
      const jp = ['50mmレンズ相当','f1.8被写界深度','極小ノイズ','シャープ'];
      const en = ['50mm lens equivalent','f1.8 shallow depth of field','very low noise','sharp'];
      const arr = langToggle.checked? en : jp;
      return arr.join(', ');
    }

    function buildPositive(){
      if(!selectedMember){
        alert('メンバーを選択してください');
        return '';
      }
      const name = selectedMember.key;
      const tp = templateText(templateSelect.value, name);
      const st = styleText(stylePreset.value);
      const md = moodText(moodPreset.value);
      const ex = extraInput.value?.trim();
      const pieces = [tp, st, md];

      if(outfitInput.value.trim()) pieces.push(langToggle.checked? `outfit: ${outfitInput.value.trim()}` : `衣装: ${outfitInput.value.trim()}`);
      if(bgInput.value.trim()) pieces.push(langToggle.checked? `background: ${bgInput.value.trim()}` : `背景: ${bgInput.value.trim()}`);
      if(addPose.checked) pieces.push(langToggle.checked? `pose: ${poseText()}` : `ポーズ: ${poseText()}`);
      if(addCamera.checked) pieces.push(cameraText());

      // メンバー特徴
      const motif = (selectedMember.motifs||[]).join(', ');
      const trait = (selectedMember.traits||[]).join(', ');
      const kw = (selectedMember.keywords||[]).join(', ');
      const profJP = `特徴: ${motif}, 性格: ${trait}, キーワード: ${kw}`;
      const profEN = `motifs: ${motif}, traits: ${trait}, keywords: ${kw}`;
      pieces.push(langToggle.checked? profEN : profJP);

      if(ex) pieces.push(ex);

      // モデル/LoRA
      if(modelInput.value.trim()) pieces.push(`[${modelInput.value.trim()}]`);

      // NSFW安全
      if(nsfwSafe.checked){
        pieces.push(langToggle.checked? 'sfw, wholesome' : '全年齢, 健全');
      }

      return pieces.filter(Boolean).join(' ');
    }

    function buildNegative(){
      return useNeg.checked ? NEGATIVE : '';
    }

    function generate(){
      const pos = buildPositive();
      if (!pos) return;
      posOut.value = pos;
      negOut.value = buildNegative();
    }

    function copyAll(){
      const txt = `Positive:\n${posOut.value}\n\nNegative:\n${negOut.value}\n\nMeta:\nres=${metaRes.value}, ${metaCfg.value}, sampler=${metaSampler.value}`;
      navigator.clipboard.writeText(txt).then(()=>{ toast('コピーしました'); });
    }

    function toast(msg){
      const t = document.createElement('div');
      t.textContent = msg;
      t.className = 'fixed bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl bg-white/10 border border-white/10 shadow-glass';
      document.body.appendChild(t);
      setTimeout(()=> t.remove(), 1600);
    }

    function saveHistory(){
      const item = {
        time: new Date().toISOString(),
        member: selectedMember?.key || '(未選択)',
        template: templateSelect.value,
        style: stylePreset.value,
        mood: moodPreset.value,
        aspect: aspectPreset.value,
        pos: posOut.value,
        neg: negOut.value,
        meta: { res: metaRes.value, cfg: metaCfg.value, sampler: metaSampler.value }
      };
      const arr = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
      arr.unshift(item);
      // 保存上限:最新から最大100件(文字列長で切るとJSONが壊れるため件数で制御)
      const MAX_ITEMS = 100;
      localStorage.setItem('holo_prompt_hist', JSON.stringify(arr.slice(0, MAX_ITEMS)));
      renderHistory();
      toast('履歴に保存しました');
    }

    function renderHistory(){
      const arr = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
      history.innerHTML = '';
      arr.forEach((it, idx)=>{
        const card = document.createElement('div');
        card.className = 'rounded-xl p-3 border border-white/10 bg-black/30';
        card.innerHTML = `
          <div class='flex items-center gap-2 mb-2'>
            <span class='text-slate-300 text-sm'>${new Date(it.time).toLocaleString()}</span>
            <span class='ms-auto text-xs chip rounded-lg px-2 py-0.5'>${it.member}</span>
          </div>
          <div class='text-xs text-slate-400 mb-2'>${it.template} / ${it.style} / ${it.mood} / ${it.aspect}</div>
          <details class='mb-2'>
            <summary class='cursor-pointer text-skin-soft'>Positive</summary>
            <pre class='whitespace-pre-wrap text-sm'>${escapeHtml(it.pos)}</pre>
          </details>
          <details class='mb-2'>
            <summary class='cursor-pointer text-skin-soft'>Negative</summary>
            <pre class='whitespace-pre-wrap text-sm'>${escapeHtml(it.neg)}</pre>
          </details>
          <div class='flex gap-2'>
            <button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='load' data-idx='${idx}'>読み込む</button>
            <button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='copy' data-idx='${idx}'>コピー</button>
            <button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='del' data-idx='${idx}'>削除</button>
          </div>
        `;
        card.addEventListener('click', e=>{
          const btn = e.target.closest('button');
          if(!btn) return;
          const { act, idx } = btn.dataset;
          const list = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
          if(act==='del'){
            list.splice(idx,1);
            localStorage.setItem('holo_prompt_hist', JSON.stringify(list));
            renderHistory();
          }else if(act==='copy'){
            navigator.clipboard.writeText(`Positive:\n${list[idx].pos}\n\nNegative:\n${list[idx].neg}`);
            toast('コピーしました');
          }else if(act==='load'){
            loadHistoryItem(list[idx]);
          }
        })
        history.appendChild(card);
      })
    }

    function loadHistoryItem(it){
      selectedMember = MEMBERS.find(m=> m.key === it.member) || null;
      templateSelect.value = it.template;
      stylePreset.value = it.style;
      moodPreset.value = it.mood;
      aspectPreset.value = it.aspect; applyAspect();
      posOut.value = it.pos; negOut.value = it.neg;
      toast('履歴を読み込みました');
      renderMembers(memberSearch.value);
    }

    clearHist.addEventListener('click', ()=>{
      localStorage.removeItem('holo_prompt_hist');
      renderHistory();
      toast('履歴を削除しました');
    });

    // JSON入出力
    exportBtn.addEventListener('click', ()=>{
      const data = localStorage.getItem('holo_prompt_hist')||'[]';
      const blob = new Blob([data], {type:'application/json'});
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = 'holo_prompt_history.json';
      a.click();
    });

    importBtn.addEventListener('click', ()=>{
      const inp = document.createElement('input');
      inp.type = 'file'; inp.accept = 'application/json';
      inp.onchange = () => {
        const file = inp.files[0];
        if(!file) return;
        const reader = new FileReader();
        reader.onload = e => {
          try{
            const arr = JSON.parse(e.target.result);
            if(Array.isArray(arr)){
              localStorage.setItem('holo_prompt_hist', JSON.stringify(arr));
              renderHistory();
              toast('JSONを読み込みました');
            } else { alert('不正なJSONです'); }
          }catch(err){ alert('読み込み失敗: '+err.message); }
        };
        reader.readAsText(file);
      };
      inp.click();
    });

    // ランダム&リセット
    randomBtn.addEventListener('click', ()=>{
      selectedMember = MEMBERS[Math.floor(Math.random()*MEMBERS.length)];
      renderMembers(memberSearch.value);
      generate();
      toast('ランダム選択しました');
    });

    resetBtn.addEventListener('click', ()=>{
      memberSearch.value=''; selectedMember=null; renderMembers('');
      templateSelect.value='portrait'; stylePreset.value='anime'; moodPreset.value='soft'; aspectPreset.value='square'; applyAspect();
      outfitInput.value=''; bgInput.value=''; modelInput.value=''; extraInput.value='';
      posOut.value=''; negOut.value='';
      toast('初期化しました');
    });

    // 生成/コピー/保存
    generateBtn.addEventListener('click', generate);
    copyBtn.addEventListener('click', copyAll);
    saveBtn.addEventListener('click', saveHistory);

    // 言語切替時に再生成
    langToggle.addEventListener('change', ()=>{ if(posOut.value) generate(); });

    // HTMLエスケープ
    function escapeHtml(str=''){
      return str.replace(/[&<>"]/g, s=> ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[s]));
    }

    // 初期描画
    renderMembers('');
    renderHistory();
  </script>
</body>
</html>

ブラウザゲーム.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
  <title>Endless Dodge ULTRA - Bullet & Boss</title>
  <style>
    :root{
      --bg1:#070816; --bg2:#0f1b38; --accent:#6ee7ff; --accent2:#9bffb7; --danger:#ff6b6b; --panel:rgba(255,255,255,.08);
      --text:#eaf2ff; --muted:#b5c0d0; --gold:#ffd166; --purple:#c4a7ff; --emerald:#86efac;
    }
    *{box-sizing:border-box}
    html,body{height:100%;}
    body{ margin:0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans JP";
      color:var(--text);
      background: radial-gradient(1200px 800px at 20% 10%, #1b2444 0%, var(--bg1) 50%), linear-gradient(160deg, var(--bg2), var(--bg1));
      overflow:hidden;}
    .wrap{position:fixed; inset:0; display:grid; grid-template-rows:auto 1fr auto;}
    header, footer{display:flex; gap:.75rem; align-items:center; justify-content:space-between; padding:.6rem .9rem; backdrop-filter: blur(6px); background:linear-gradient( to bottom, rgba(255,255,255,.06), rgba(255,255,255,.02)); border-bottom:1px solid rgba(255,255,255,.08)}
    header h1{font-size:1rem; margin:0; letter-spacing:.05em; font-weight:700}
    header .right{display:flex; gap:.5rem; align-items:center}
    .pill{ pointer-events:auto; border:1px solid rgba(255,255,255,.14); background:var(--panel); padding:.5rem .8rem; border-radius:999px; font-size:.9rem; color:var(--text); cursor:pointer; user-select:none; transition:transform .08s ease}
    .pill:active{ transform:scale(.97)}
    #gamePanel{ position:relative; display:grid; place-items:center;}
    canvas{ width: min(94vw, 800px); aspect-ratio: 9/16; border-radius: 18px; box-shadow: 0 10px 40px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.06);
      background: radial-gradient(600px 500px at 50% 10%, rgba(110,231,255,.12), transparent 60%), linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.02));}
    .hud{ position:absolute; inset:0; pointer-events:none;}
    .row{ display:flex; justify-content:space-between; align-items:center; padding:10px;}
    .score{ font-variant-numeric: tabular-nums; font-size: clamp(18px, 3.5vw, 28px); text-shadow:0 1px 0 rgba(0,0,0,.5)}
    .muted{ color: var(--muted)}
    .center{ position:absolute; inset:0; display:grid; place-items:center;}
    .card{ pointer-events:auto; background:rgba(7,8,22,.92); border:1px solid rgba(255,255,255,.14); border-radius:16px; padding:20px; width:min(92vw, 480px); box-shadow:0 20px 60px rgba(0,0,0,.6)}
    .card h2{ margin:0 0 8px; font-size:1.25rem}
    .card p{ margin:.25rem 0; color:var(--muted)}
    .btn{ display:inline-flex; align-items:center; justify-content:center; gap:.5rem; padding:.7rem 1rem; border-radius:12px; border:1px solid rgba(255,255,255,.16); background:linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.06)); color:var(--text); cursor:pointer; font-weight:600}
    .btn:hover{ filter:brightness(1.08)}
    .btn.primary{ border-color: rgba(110,231,255,.5); box-shadow: 0 0 30px rgba(110,231,255,.15) inset}
    .grid{ display:grid; grid-template-columns:1fr 1fr; gap:.6rem}
    .touch{ position:absolute; inset:auto 0 10px 0; display:flex; justify-content:center; gap:12px; pointer-events:auto}
    .touch button{ width:clamp(64px, 22vw, 106px); aspect-ratio:1/1; border-radius:16px; border:1px solid rgba(255,255,255,.14); background:var(--panel); color:var(--text); font-weight:700; font-size:clamp(16px, 4.5vw, 22px); text-shadow:0 1px 0 rgba(0,0,0,.35)}
    .badge{border:1px solid rgba(255,255,255,.14); background:var(--panel); padding:.35rem .6rem; border-radius:999px; font-size:.75rem}
    .toast{ position:absolute; left:50%; top:14%; transform:translateX(-50%); pointer-events:none; opacity:0; transition: opacity .2s, transform .2s; background:rgba(0,0,0,.5); border:1px solid rgba(255,255,255,.18); padding:.35rem .7rem; border-radius:10px; font-weight:700}
    .toast.show{ opacity:1; transform:translate(-50%, -6px)}
    footer{ border-top:1px solid rgba(255,255,255,.08); border-bottom:none; justify-content:center}
    a{ color:var(--accent)}
    dialog{ border:none; border-radius:16px; background:rgba(7,8,22,.96); color:var(--text); width:min(92vw,560px); }
    dialog::backdrop{ background:rgba(0,0,0,.6); }
    .field{ display:flex; justify-content:space-between; align-items:center; gap:10px; padding:8px 0; }
    .range{ width:58% }
    .switch{ appearance:none; width:42px; height:24px; border-radius:999px; background:#445; position:relative; outline:none; cursor:pointer; }
    .switch:checked{ background:#2aa }
    .switch::after{ content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; transition:left .15s}
    .switch:checked::after{ left:21px }
    .shop-item{ display:grid; grid-template-columns:1fr auto; gap:.4rem; align-items:center; padding:.5rem; border:1px solid rgba(255,255,255,.12); border-radius:12px; margin:.35rem 0; }
    .chip{ padding:.2rem .5rem; border:1px solid rgba(255,255,255,.16); border-radius:999px; font-size:.75rem; }
  </style>
</head>
<body>
  <div class="wrap">
    <header>
      <h1>Endless Dodge <span class="badge">ULTRA</span></h1>
      <div class="right">
        <span class="badge">💎 <span id="wallet">0</span></span>
        <button id="btnShop" class="pill" aria-label="shop">🛒 ショップ</button>
        <button id="btnSkins" class="pill" aria-label="skins">🎨 スキン</button>
        <button id="btnPause" class="pill" aria-label="pause">⏸</button>
        <button id="btnSound" class="pill" aria-label="sound">🔊</button>
        <button id="btnSettings" class="pill" aria-label="settings">⚙</button>
      </div>
    </header>
    <div id="gamePanel">
      <canvas id="game" width="360" height="640" aria-label="game canvas"></canvas>
      <div class="hud">
        <div class="row">
          <div class="score">
            <span id="score">0</span> pts
            · <span class="muted">Best:</span> <span id="best">0</span>
            · <span class="muted">Combo:</span> <span id="combo">x1.0</span>
            · <span class="muted">Stage:</span> <span id="stage">1</span>
          </div>
          <div class="row" style="gap:.5rem">
            <span class="badge" id="badges">⛨ 0 · 🧲 0 · ⏳ 0</span>
          </div>
        </div>
        <div class="center" id="overlayStart">
          <div class="card">
            <h2>避けて、撃って、強化して、ボスを倒せ!</h2>
            <p>← → / A・D で移動。<strong>Spaceでショット</strong>、<kbd>Shift</kbd>でダッシュ(無敵0.4s)。</p>
            <p>パワーアップ:⛨シールド / 🧲マグネット / ⏳スロウ。コンボでスコア倍率UP。</p>
            <p>ステージごとにボス戦。ボスは弾幕を発射。ショットでHPを削ろう。</p>
            <div class="grid" style="margin-top:10px">
              <button class="btn primary" id="btnStart">▶ ゲーム開始</button>
              <button class="btn" id="btnHow">❓ 操作</button>
            </div>
            <div style="margin-top:10px" class="muted" id="missions"></div>
          </div>
        </div>
        <div class="center" id="overlayBoss" style="display:none">
          <div class="card" style="text-align:center">
            <h2>⚠ B O S S ⚠</h2>
            <p>弾幕を避けつつ、Spaceで撃て!Shiftダッシュも活用。</p>
            <button class="btn primary" id="btnBossGo">戦闘開始</button>
          </div>
        </div>
        <div class="center" id="overlayGameOver" style="display:none">
          <div class="card">
            <h2>ゲームオーバー</h2>
            <p>スコア: <strong id="finalScore">0</strong> / ベスト: <strong id="finalBest">0</strong> / 💎<strong id="earned">0</strong></p>
            <p>達成:<span id="finalMissions" class="muted">-</span></p>
            <div class="grid" style="margin-top:10px">
              <button class="btn primary" id="btnRetry">↻ リトライ</button>
              <button class="btn" id="btnHome">⌂ タイトル</button>
            </div>
          </div>
        </div>
        <div class="touch" id="touchControls" aria-hidden="true">
          <button id="leftBtn" aria-label="left">⟵</button>
          <button id="dashBtn" aria-label="dash">⇧</button>
          <button id="rightBtn" aria-label="right">⟶</button>
        </div>
        <div class="toast" id="toast">Ready</div>
      </div>
    </div>
    <footer>
      <small class="muted">© 2025 Endless Dodge ULTRA · 図形のみ · ローカル保存(設定/進行/ウォレット/実績)</small>
    </footer>
  </div>

  <!-- Settings / Shop / Skins (unchanged structure) -->
  <dialog id="dlgSettings">
    <form method="dialog" style="padding:16px">
      <h3 style="margin:0 0 8px">設定</h3>
      <div class="field"><span>難易度(速度倍率)</span><input class="range" id="rangeSpeed" type="range" min="0.8" max="1.6" step="0.05"></div>
      <div class="field"><span>画面シェイク</span><input id="chkShake" class="switch" type="checkbox"></div>
      <div class="field"><span>色弱モード(高コントラスト)</span><input id="chkCB" class="switch" type="checkbox"></div>
      <div class="field"><span>省エネ描画(★数減少)</span><input id="chkEco" class="switch" type="checkbox"></div>
      <div class="field"><span>操作ヒントの表示</span><input id="chkHints" class="switch" type="checkbox"></div>
      <div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px">
        <button class="btn" value="cancel">閉じる</button>
        <button class="btn primary" id="btnSaveSettings" value="default">保存</button>
      </div>
    </form>
  </dialog>

  <dialog id="dlgShop"><form method="dialog" style="padding:16px"><h3 style="margin:0 0 8px">ショップ</h3><p class="muted">💎はプレイ後にスコアから換算(100pts ≒ 1💎)。</p><div id="shopList"></div><div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px"><button class="btn" value="cancel">閉じる</button></div></form></dialog>
  <dialog id="dlgSkins"><form method="dialog" style="padding:16px"><h3 style="margin:0 0 8px">スキン</h3><div id="skinList"></div><div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px"><button class="btn" value="cancel">閉じる</button></div></form></dialog>

  <script>
  // ===== Utilities & Persistence =====
  const clamp=(v,min,max)=>Math.max(min,Math.min(max,v));
  const rand=(a,b)=>Math.random()*(b-a)+a; const choice=a=>a[(Math.random()*a.length)|0];
  const storage={ get(k,def){ try{return JSON.parse(localStorage.getItem(k)) ?? def}catch{ return def }}, set(k,v){ localStorage.setItem(k, JSON.stringify(v)); } };

  const SAVE={ best:'ultra-best', opts:'ultra-opts', stats:'ultra-stats', wallet:'ultra-wallet', upgrades:'ultra-upgrades', missions:'ultra-missions', skin:'ultra-skin' };
  const opts = Object.assign({ speedMul:1.0, shake:true, colorblind:false, eco:false, hints:true }, storage.get(SAVE.opts, {})); storage.set(SAVE.opts, opts);
  const wallet = { gems: storage.get(SAVE.wallet, 0) };
  function addGems(n){ wallet.gems = Math.max(0, Math.floor(wallet.gems + n)); storage.set(SAVE.wallet, wallet.gems); walletEl.textContent = wallet.gems; }

  const upgrades = Object.assign({ startShield:0, magnetDur:0, dashCD:0, scoreMul:0, extraLife:0 }, storage.get(SAVE.upgrades, {}));
  function uLevel(name){ return upgrades[name]||0 } function saveUpgrades(){ storage.set(SAVE.upgrades, upgrades); buildShop(); }

  const skins = [
    {id:'default', name:'Default', cost:0, color:'#eaf2ff'},
    {id:'neon', name:'Neon Blue', cost:50, color:'#7ee0ff'},
    {id:'sun', name:'Sun Gold', cost:80, color:'#ffd166'},
    {id:'void', name:'Void Purple', cost:120, color:'#c4a7ff'},
    {id:'leaf', name:'Leaf Green', cost:120, color:'#86efac'}
  ];
  let currentSkin = storage.get(SAVE.skin, 'default');

  function toast(msg, t=1200){ const el=document.getElementById('toast'); el.textContent=msg; el.classList.add('show'); clearTimeout(el._t); el._t=setTimeout(()=>el.classList.remove('show'), t); }

  // ===== Audio =====
  const AudioKit=(()=>{ let ctx, enabled=false; function ensure(){ if(!ctx){ const C=window.AudioContext||window.webkitAudioContext; if(C){ ctx=new C(); }} return ctx }
    function beep(freq=440, dur=0.08, type='sine', gain=0.02){ if(!enabled) return; const c=ensure(); if(!c) return; const o=c.createOscillator(); const g=c.createGain(); o.type=type; o.frequency.setValueAtTime(freq,c.currentTime); g.gain.setValueAtTime(gain,c.currentTime); o.connect(g).connect(c.destination); const t=c.currentTime; o.start(t); o.stop(t+dur); }
    function arpeggio(){ if(!enabled) return; const c=ensure(); if(!c) return; const base=220; const seq=[0,4,7,12,7,4]; seq.forEach((st,i)=>{ const o=c.createOscillator(); const g=c.createGain(); o.type='triangle'; o.frequency.setValueAtTime(base*Math.pow(2,st/12), c.currentTime + i*0.08); g.gain.setValueAtTime(0.02, c.currentTime + i*0.08); o.connect(g).connect(c.destination); o.start(c.currentTime + i*0.08); o.stop(c.currentTime + i*0.08 + 0.1); }); }
    return{ enable(){ enabled=true; ensure(); }, disable(){ enabled=false; }, toggle(){ enabled=!enabled; if(enabled) ensure(); return enabled; }, hit(){ beep(120,0.18,'square',0.05); }, coin(){ beep(880,0.07,'triangle',0.03); }, tick(){ beep(660,0.02,'sine',0.015); }, power(){ beep(520,0.1,'sawtooth',0.04); }, dash(){ beep(240,0.06,'square',0.05); }, fanfare(){ arpeggio(); }, shoot(){ beep(720,0.04,'square',0.03); } }
  })();

  // ===== Canvas & World =====
  const canvas=document.getElementById('game'); const ctx=canvas.getContext('2d');
  let dpr=1; function resize(){ dpr=Math.max(1, Math.min(2, window.devicePixelRatio||1)); const w=canvas.clientWidth; const h=canvas.clientHeight; canvas.width=Math.round(w*dpr); canvas.height=Math.round(h*dpr); ctx.setTransform(dpr,0,0,dpr,0,0); }
  new ResizeObserver(resize).observe(canvas); window.addEventListener('orientationchange', resize); resize();

  const state={ running:false, over:false, t:0, score:0, best: storage.get(SAVE.best, 0), baseSpeed:120, speed:120, worldW:360, worldH:640, combo:1, comboTime:0, slowed:0, stage:1, boss:false };
  const fx={ shakeTime:0, shakeAmp:0 };
  const starCount = opts.eco? 40 : 90; const stars=[...Array(starCount)].map(()=>({x:rand(0,360), y:rand(0,640), s:rand(0.5,2), sp:rand(10,40)}));

  const player={ x:180, y:560, r:12, vx:0, speed:270, color:'#eaf2ff', alive:true, flash:0, shield:0, magnet:0, dashCD:0, dashT:0, extra:0, fireCD:0 };
  const obstacles=[]; const coins=[]; const lasers=[]; const particles=[]; const powerups=[]; const bullets=[]; // boss bullets
  const pbullets=[]; // player bullets

  // ===== Spawning =====
  let lastSpawn=0, spawnInt=0.9; let lastLaser=0, laserInt=6.0; let stageTime=0, nextBossAt=28; // seconds
  function spawnBlockRow(yOff=-40){ const gap = clamp(140 - state.t*0.02, 70, 150); const blockW = rand(40, 90); const leftW = rand(10, state.worldW - gap - blockW - 10); const rightX = leftW + gap + blockW; const moving = Math.random()<clamp(0.08 + state.t*0.0006, 0.08, 0.4); const speed = moving? rand(30, 90)* (Math.random()<0.5?-1:1) : 0; obstacles.push({x:0, y:yOff, w:leftW, h:16, vx:0}); obstacles.push({x:rightX, y:yOff, w: state.worldW - rightX, h:16, vx:0}); if(moving){ obstacles.push({x:leftW+4, y:yOff-18, w: blockW-8, h:10, vx:speed}); }
    const cx = leftW + gap/2 + rand(-gap*0.35, gap*0.35); const cluster = (Math.random()<0.6) ? 4 : 1; for(let i=0;i<cluster;i++) coins.push({x:cx + (cluster>1?(i-1.5)*10:0), y:yOff-20 - i*8, r:6, vy:0}); if(Math.random()<0.22) powerups.push({x:cx+rand(-gap*0.3,gap*0.3), y:yOff-36, r:8, kind: choice(['shield','magnet','slow'])}); }
  function spawnLaser(){ const side = Math.random()<0.5? 'L':'R'; const x = side==='L'? -40 : state.worldW+40; const dir = side==='L'? 1 : -1; lasers.push({x, y: rand(120, state.worldH-160), w:120, h:10, vx: 170*dir, life: 4}); }

  // ===== Boss & Bullet Hell =====
  let boss=null; let patternT=0, patternId=0, spiralAng=0; // patterns
  function enterBoss(){ state.boss=true; show(bossOverlay); }
  function startBoss(){ hide(bossOverlay); boss = { x: state.worldW/2, y: 160, r: 22, hp: 6 + state.stage*2, vx: 80 }; bullets.length=0; patternT=0; patternId=0; spiralAng=0; }
  function bossShootFan(){ // 扇状(自機狙い)
    const dx = player.x - boss.x; const dy = (player.y - boss.y); const base = Math.atan2(dy, dx); const n=5; const spread=0.6; for(let i=0;i<n;i++){ const a = base + (i-(n-1)/2)*spread/n; bullets.push({x:boss.x, y:boss.y, r:4, vx:Math.cos(a)*160, vy:Math.sin(a)*160}); }
  }
  function bossShootRing(){ // 全方位リング
    const n=14; for(let i=0;i<n;i++){ const a = (i/n)*Math.PI*2; bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a)*120, vy:Math.sin(a)*120}); }
  }
  function bossShootSpiral(){ // 渦巻き
    const a1 = spiralAng; const a2 = spiralAng + Math.PI; spiralAng += 0.35; bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a1)*150, vy:Math.sin(a1)*150}); bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a2)*150, vy:Math.sin(a2)*150}); }

  function updateBoss(dt){ if(!boss) return; boss.x += boss.vx*dt; if(boss.x<40){ boss.x=40; boss.vx=Math.abs(boss.vx);} if(boss.x>state.worldW-40){ boss.x=state.worldW-40; boss.vx=-Math.abs(boss.vx);} // pattern timeline
    patternT += dt; if(patternId===0){ if(patternT>0.6){ bossShootFan(); patternT=0; if(Math.random()<0.25) patternId=1; } }
    else if(patternId===1){ bossShootSpiral(); if(patternT>2.4){ patternT=0; patternId=2; } }
    else if(patternId===2){ if(patternT>1.0){ bossShootRing(); patternT=0; if(Math.random()<0.5) patternId=0; else patternId=1; } }

    // move bullets
    for(const b of bullets){ b.x += b.vx*dt; b.y += b.vy*dt; }
    for(let i=bullets.length-1;i>=0;i--){ const b=bullets[i]; if(b.x<-40||b.x>state.worldW+40||b.y<-40||b.y>state.worldH+60) bullets.splice(i,1); }

    // hit player
    for(const b of bullets){ const dx=player.x-b.x, dy=player.y-b.y; if(dx*dx+dy*dy <= (player.r+b.r)*(player.r+b.r)){ if(player.dashT<=0){ if(player.shield>0){ player.shield-=1; emit(player.x,player.y,12,'#6ee7ff'); } else if(player.extra>0){ player.extra--; toast('Extra Life!'); } else { return gameOver(); } } } }
  }
  function damageBoss(dmg=1){ if(!boss) return; boss.hp-=dmg; emit(boss.x,boss.y,16,'#c4a7ff'); if(boss.hp<=0){ boss=null; state.boss=false; state.stage++; stageTime=0; nextBossAt = clamp(26 - state.stage, 18, 26); addScore(200); toast(`Stage ${state.stage} クリア!`); AudioKit.fanfare(); }
  }

  function emit(x,y, n=8, col='#a8ffce'){ for(let i=0;i<n;i++){ particles.push({x,y, vx:rand(-90,90), vy:rand(-120,-40), life: rand(.3,.75), col}) } }

  // ===== Input =====
  let left=false, right=false, dashReq=false, shootHold=false;
  window.addEventListener('keydown',e=>{
    if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A') left=true;
    if(e.key==='ArrowRight'||e.key==='d'||e.key==='D') right=true;
    if(e.code==='Space'){ shootHold=true; e.preventDefault(); }
    if(e.key==='Shift') dashReq=true;
  });
  window.addEventListener('keyup',e=>{
    if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A') left=false;
    if(e.key==='ArrowRight'||e.key==='d'||e.key==='D') right=false;
    if(e.code==='Space') shootHold=false;
  });

  const leftBtn=document.getElementById('leftBtn'); const rightBtn=document.getElementById('rightBtn'); const dashBtn=document.getElementById('dashBtn');
  const tp=document.getElementById('touchControls'); const isMobile = /Mobi|Android/i.test(navigator.userAgent); tp.style.display = isMobile? 'flex':'none';
  const press=(b)=>{ b.dataset.down='1'; if(b===leftBtn) left=true; else if(b===rightBtn) right=true; else dashReq=true; };
  const release=(b)=>{ b.dataset.down='0'; if(b===leftBtn) left=false; else if(b===rightBtn) right=false; };
  [leftBtn,rightBtn,dashBtn].forEach(b=>{ b.addEventListener('pointerdown',()=>press(b)); b.addEventListener('pointerup',()=>release(b)); b.addEventListener('pointerleave',()=>release(b)); });
  // mobile taps: single tap=shot, two-finger=dash
  canvas.addEventListener('touchstart',e=>{ if(e.touches.length>=2) { dashReq=true; } else { shootOnce(); } }, {passive:true});
  // desktop click to shoot too
  canvas.addEventListener('mousedown', shootOnce);

  // ===== Loop =====
  let last=performance.now(); function loop(t){ const dt=Math.min(0.033,(t-last)/1000); last=t; if(state.running) update(dt); draw(dt); requestAnimationFrame(loop); } requestAnimationFrame(loop);

  // ===== Mechanics =====
  const stats = { coins:0, dash:0, maxCombo:1, shield:0, score:0 };

  function reset(){ state.running=false; state.over=false; state.t=0; state.score=0; state.stage=1; stageTime=0; nextBossAt=28; state.speed=state.baseSpeed*opts.speedMul; state.combo=1; state.comboTime=0; state.slowed=0; state.boss=false; fx.shakeTime=0; fx.shakeAmp=0; boss=null;
    obstacles.length=0; coins.length=0; particles.length=0; lasers.length=0; powerups.length=0; bullets.length=0; pbullets.length=0;
    player.x=state.worldW/2; player.alive=true; player.flash=0; player.shield=0; player.magnet=0; player.dashCD=Math.max(0,2.6 - uLevel('dashCD')*0.4); player.dashT=0; player.extra = uLevel('extraLife'); player.fireCD=0;
    if(uLevel('startShield')>0) player.shield = 0.8 + 0.4*uLevel('startShield');
    spawnBlockRow(0); updateUI(); }

  function start(){ state.running=true; hide(startOverlay); hide(gameoverOverlay); hide(bossOverlay); AudioKit.tick(); }
  function gameOver(){ state.running=false; state.over=true; player.alive=false; AudioKit.hit(); state.best=Math.max(state.best, Math.floor(state.score)); storage.set(SAVE.best, state.best); const earned = Math.floor((state.score * (1 + 0.1*uLevel('scoreMul')))/100); addGems(earned); finalScore.textContent = Math.floor(state.score); finalBest.textContent = state.best; earnedEl.textContent = earned; finalMissions.textContent = summarizeMissions(); show(gameoverOverlay); updateUI(); }

  function updateUI(){ scoreEl.textContent = Math.floor(state.score); bestEl.textContent = state.best; comboEl.textContent = 'x'+state.combo.toFixed(1); badgesEl.textContent = `⛨ ${Math.ceil(player.shield)} · 🧲 ${Math.ceil(player.magnet)} · ⏳ ${Math.ceil(state.slowed)}`; stageEl.textContent = state.stage; walletEl.textContent = wallet.gems; }

  function addScore(v){ state.score += v * (1 + 0.1*uLevel('scoreMul')) * state.combo; stats.score = Math.floor(state.score); }
  function addCombo(dt){ state.combo = clamp(state.combo + dt*0.05, 1, 5); state.comboTime = 1.8; stats.maxCombo = Math.max(stats.maxCombo, state.combo); }

  function doDash(){ if(player.dashT>0 || player.dashCD>0) return; player.dashT=0.4; player.dashCD=Math.max(0.8, 3.0 - uLevel('dashCD')*0.4); stats.dash++; AudioKit.dash(); toast('Dash!'); fx.shakeTime=0.12; fx.shakeAmp=4; }

  function applyPower(kind){ if(kind==='shield'){ player.shield = Math.max(player.shield, 1.5 + 0.2*uLevel('startShield')); stats.shield++; toast('Shield ⛨'); }
    else if(kind==='magnet'){ player.magnet = Math.max(player.magnet, 4.5 + 0.5*uLevel('magnetDur')); toast('Magnet 🧲'); }
    else if(kind==='slow'){ state.slowed = Math.max(state.slowed, 2.5); toast('Slow ⏳'); }
    AudioKit.power(); }

  function collideCircleRect(cx,cy,cr, r){ const tx=clamp(cx, r.x, r.x+r.w); const ty=clamp(cy, r.y, r.y+r.h); const dx=cx-tx, dy=cy-ty; return dx*dx+dy*dy <= cr*cr; }

  function tryFire(){ if(player.fireCD>0) return; // fire 1~3 shots based on combo
    const n = (state.combo>=3.5? 3 : (state.combo>=2.0? 2:1));
    for(let i=0;i<n;i++){
      const off = (n===1)?0:(i-(n-1)/2)*6; pbullets.push({x:player.x+off, y:player.y-player.r-2, r:3, vy:-380});
    }
    player.fireCD = Math.max(0.08, 0.22 - (state.combo-1)*0.02);
    AudioKit.shoot();
  }
  function shootOnce(){ tryFire(); }

  function update(dt){
    state.t += dt; stageTime += dt; const speedMul = opts.speedMul * (state.slowed>0? 0.55:1); state.speed = clamp(120 + state.t*6, 120, 540) * speedMul; spawnInt = clamp(0.9 - state.t*0.02, 0.26, 0.9); laserInt = clamp(6.0 - state.t*0.01, 3.0, 6.0);

    if(!state.boss && stageTime>=nextBossAt){ enterBoss(); }

    lastSpawn += dt; if(lastSpawn>=spawnInt && !state.boss){ lastSpawn=0; spawnBlockRow(-20); }
    lastLaser += dt; if(lastLaser>=laserInt && !state.boss){ lastLaser=0; spawnLaser(); }

    // Player movement & actions
    const dir = (right?1:0) - (left?1:0);
    const skinCol = skins.find(s=>s.id===currentSkin)?.color || '#eaf2ff'; player.color = skinCol;
    player.vx = dir * player.speed * (player.dashT>0? 1.6:1);
    player.x = clamp(player.x + player.vx * dt, player.r+2, state.worldW - player.r-2);
    if(dashReq){ doDash(); dashReq=false; }
    if(player.dashT>0) player.dashT-=dt; if(player.dashCD>0) player.dashCD-=dt;
    if(player.fireCD>0) player.fireCD-=dt; if(shootHold) tryFire();

    // Stars
    for(const s of stars){ s.y += (state.speed*0.2 + s.sp) * dt; if(s.y>state.worldH) { s.y -= state.worldH; s.x = rand(0,state.worldW);} }

    // Entities movement
    for(const o of obstacles){ o.y += state.speed * dt; o.x += (o.vx||0) * dt; if(o.x<0){ o.x=0; o.vx=Math.abs(o.vx||0);} if(o.x+o.w>state.worldW){ o.x=state.worldW-o.w; o.vx = -Math.abs(o.vx||0);} }
    for(const c of coins){ c.y += (state.speed*0.95) * dt; const ax = (player.magnet>0? (player.x - c.x)*1.6 : 0); const ay = (player.magnet>0? (player.y - c.y)*1.6 : 0); c.x += ax*dt; c.y += ay*dt; }
    for(const p of particles){ p.x += p.vx*dt; p.y += p.vy*dt; p.vy += 420*dt; p.life -= dt; }
    for(const l of lasers){ l.x += l.vx*dt; l.life -= dt; }
    for(const pb of pbullets){ pb.y += pb.vy*dt; }

    if(state.boss){ updateBoss(dt); }

    // Clean
    while(obstacles.length && obstacles[0].y>state.worldH+40) obstacles.shift();
    while(coins.length && coins[0].y>state.worldH+40) coins.shift();
    for(let i=particles.length-1;i>=0;i--) if(particles[i].life<=0) particles.splice(i,1);
    for(let i=lasers.length-1;i>=0;i--) if(lasers[i].life<=0 || lasers[i].x<-160 || lasers[i].x>state.worldW+160) lasers.splice(i,1);
    for(let i=pbullets.length-1;i>=0;i--) if(pbullets[i].y<-30) pbullets.splice(i,1);

    for(let i=powerups.length-1;i>=0;i--) if(powerups[i].y>state.worldH+40) powerups.splice(i,1);
    for(const u of powerups){ u.y += state.speed*0.9*dt; }

    // Collisions with hazards
    let hit=false; if(!state.boss){ for(const o of obstacles){ if(collideCircleRect(player.x,player.y,player.r, o)) { hit=true; break; } } for(const l of lasers){ const r={x:l.x-4, y:l.y-2, w:l.w+8, h:l.h+4}; if(collideCircleRect(player.x,player.y,player.r, r)) { hit=true; break; } } }
    if(hit && player.dashT<=0){ if(player.shield>0){ player.shield-=0.9; emit(player.x, player.y, 14, '#6ee7ff'); fx.shakeTime=0.18; fx.shakeAmp=6; } else if(player.extra>0){ player.extra--; toast('Extra Life!'); emit(player.x,player.y,12,'#86efac'); } else { player.flash=0.18; emit(player.x, player.y, 18, '#ff7777'); return gameOver(); } }

    // coins
    for(let i=coins.length-1;i>=0;i--){ const c=coins[i]; const dx=player.x-c.x, dy=player.y-c.y; if(dx*dx+dy*dy < (player.r+c.r)*(player.r+c.r)){ coins.splice(i,1); addScore(10); addCombo(0.25); stats.coins++; AudioKit.coin(); emit(c.x,c.y,6,'#ffd166'); if(state.boss && boss){ damageBoss(0.3); } } }

    // powerups
    for(let i=powerups.length-1;i>=0;i--){ const u=powerups[i]; const dx=player.x-u.x, dy=player.y-u.y; if(dx*dx+dy*dy < (player.r+u.r)*(player.r+u.r)){ powerups.splice(i,1); applyPower(u.kind); addScore(5); } }

    // player bullets vs boss
    if(boss){ for(let i=pbullets.length-1;i>=0;i--){ const pb=pbullets[i]; const dx=boss.x-pb.x, dy=boss.y-pb.y; if(dx*dx+dy*dy <= (boss.r+pb.r)*(boss.r+pb.r)){ pbullets.splice(i,1); damageBoss(1); addScore(2); } } }

    // Effects timers
    if(player.shield>0) player.shield=Math.max(0, player.shield-dt);
    if(player.magnet>0) player.magnet=Math.max(0, player.magnet-dt);
    if(state.slowed>0) state.slowed=Math.max(0, state.slowed-dt);
    if(player.flash>0) player.flash=Math.max(0, player.flash-0.016);
    if(state.comboTime>0){ state.comboTime-=dt; if(state.comboTime<=0) state.combo = Math.max(1, state.combo-0.1); }

    // Score by time
    addScore(dt*3); updateUI();
  }

  // ===== Rendering =====
  function draw(){ const w=canvas.width/dpr, h=canvas.height/dpr; const sx = (fx.shakeTime>0 && opts.shake)? (rand(-fx.shakeAmp,fx.shakeAmp)) : 0; const sy = (fx.shakeTime>0 && opts.shake)? (rand(-fx.shakeAmp,fx.shakeAmp)) : 0; if(fx.shakeTime>0) fx.shakeTime -= 1/60; ctx.save(); ctx.clearRect(0,0,w,h); ctx.translate(sx, sy);
    const obCol = opts.colorblind? 'rgba(255,255,255,.9)': 'rgba(255,255,255,.14)';
    ctx.save(); ctx.globalAlpha=0.9; for(const s of stars){ ctx.fillStyle = `rgba(255,255,255,${0.2 + s.s*0.2})`; ctx.fillRect(s.x, s.y, s.s, s.s); } ctx.restore();
    ctx.save(); ctx.globalAlpha=0.06; ctx.lineWidth=1; const grid=20; ctx.beginPath(); for(let x=0;x<w;x+=grid){ ctx.moveTo(x,0); ctx.lineTo(x,h);} for(let y=0;y<h;y+=grid){ ctx.moveTo(0,y); ctx.lineTo(w,y);} ctx.strokeStyle='white'; ctx.stroke(); ctx.restore();

    // coins
    ctx.save(); for(const c of coins){ ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, Math.PI*2); ctx.fillStyle = opts.colorblind? '#ffbf00' : 'var(--gold)'; ctx.fill(); ctx.lineWidth=1; ctx.strokeStyle='rgba(0,0,0,.25)'; ctx.stroke(); } ctx.restore();
    // powerups
    ctx.save(); for(const u of powerups){ ctx.beginPath(); ctx.arc(u.x, u.y, u.r, 0, Math.PI*2); ctx.fillStyle = u.kind==='shield'? '#6ee7ff' : (u.kind==='magnet'? '#9bffb7' : '#c4a7ff'); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,.3)'; ctx.stroke(); ctx.font='10px system-ui'; ctx.fillStyle='#001'; const sym = u.kind==='shield'? '⛨' : (u.kind==='magnet'? '🧲' : '⏳'); ctx.fillText(sym, u.x-6, u.y+3); } ctx.restore();
    // obstacles & lasers (no boss phase)
    if(!state.boss){ ctx.save(); ctx.fillStyle=obCol; for(const o of obstacles){ ctx.fillRect(o.x, o.y, o.w, o.h); } ctx.restore(); ctx.save(); for(const l of lasers){ const grad=ctx.createLinearGradient(l.x, l.y, l.x+l.w, l.y+l.h); grad.addColorStop(0,'rgba(255,90,90,.85)'); grad.addColorStop(1,'rgba(255,160,160,.5)'); ctx.fillStyle=grad; ctx.fillRect(l.x, l.y, l.w, l.h); } ctx.restore(); }
    // boss
    if(state.boss && boss){ ctx.save(); const g=ctx.createRadialGradient(boss.x-6,boss.y-6,4, boss.x,boss.y,boss.r+6); g.addColorStop(0,'#fff'); g.addColorStop(1,'#c4a7ff'); ctx.fillStyle=g; ctx.beginPath(); ctx.arc(boss.x,boss.y,boss.r,0,Math.PI*2); ctx.fill(); ctx.fillStyle='rgba(255,255,255,.8)'; ctx.fillRect(boss.x-24,boss.y-boss.r-16,48,6); ctx.fillStyle='#ff6bcb'; const hpw = clamp((boss.hp/(6+state.stage*2))*48,0,48); ctx.fillRect(boss.x-24,boss.y-boss.r-16,hpw,6); ctx.restore(); ctx.save(); ctx.fillStyle='#ff9d9d'; for(const b of bullets){ ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill(); } ctx.restore(); }
    // player bullets
    ctx.save(); ctx.fillStyle='#aee3ff'; for(const pb of pbullets){ ctx.beginPath(); ctx.arc(pb.x,pb.y,pb.r,0,Math.PI*2); ctx.fill(); } ctx.restore();
    // player
    ctx.save(); if(player.flash>0){ ctx.shadowColor=getCSS('--danger', '#ff6b6b'); ctx.shadowBlur=18; }
    ctx.beginPath(); ctx.arc(player.x, player.y, player.r, 0, Math.PI*2); const grad=ctx.createRadialGradient(player.x-4,player.y-6,4, player.x,player.y, player.r+6); grad.addColorStop(0, '#ffffff'); grad.addColorStop(1, player.color||'#7ee0ff'); ctx.fillStyle=grad; ctx.fill(); if(player.shield>0){ ctx.globalAlpha=0.25+0.15*Math.sin(performance.now()/120); ctx.beginPath(); ctx.arc(player.x, player.y, player.r+6, 0, Math.PI*2); ctx.strokeStyle='#8ae9ff'; ctx.lineWidth=3; ctx.stroke(); ctx.globalAlpha=1; } if(player.dashT>0){ ctx.globalAlpha=0.5; ctx.beginPath(); ctx.arc(player.x - 10, player.y, player.r*0.9, 0, Math.PI*2); ctx.fillStyle='#bde3ff'; ctx.fill(); ctx.globalAlpha=1; } ctx.restore();
    // particles
    ctx.save(); for(const p of particles){ ctx.globalAlpha = clamp(p.life,0,1); ctx.fillStyle=p.col||'#a8ffce'; ctx.fillRect(p.x, p.y, 2,2); } ctx.restore();
    ctx.restore();
  }

  function getCSS(name, fallback){ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; }

  // ===== UI wires =====
  const startOverlay=document.getElementById('overlayStart'); const gameoverOverlay=document.getElementById('overlayGameOver'); const bossOverlay=document.getElementById('overlayBoss');
  const scoreEl=document.getElementById('score'); const bestEl=document.getElementById('best'); const comboEl=document.getElementById('combo'); const stageEl=document.getElementById('stage'); const badgesEl=document.getElementById('badges');
  const btnStart=document.getElementById('btnStart'); const btnRetry=document.getElementById('btnRetry'); const btnHome=document.getElementById('btnHome'); const btnPause=document.getElementById('btnPause'); const btnSound=document.getElementById('btnSound'); const btnSettings=document.getElementById('btnSettings'); const btnShop=document.getElementById('btnShop'); const btnSkins=document.getElementById('btnSkins'); const btnBossGo=document.getElementById('btnBossGo');
  const dlgSettings=document.getElementById('dlgSettings'); const rangeSpeed=document.getElementById('rangeSpeed'); const chkShake=document.getElementById('chkShake'); const chkCB=document.getElementById('chkCB'); const chkEco=document.getElementById('chkEco'); const chkHints=document.getElementById('chkHints'); const missionsEl=document.getElementById('missions'); const walletEl=document.getElementById('wallet');
  const finalScore=document.getElementById('finalScore'); const finalBest=document.getElementById('finalBest'); const earnedEl=document.getElementById('earned'); const finalMissions=document.getElementById('finalMissions');

  function show(el){ el.style.display='grid'; } function hide(el){ el.style.display='none'; }
  btnStart.addEventListener('click',()=>{ start(); AudioKit.enable(); }); btnRetry.addEventListener('click',()=>{ reset(); start(); }); btnHome.addEventListener('click',()=>{ reset(); show(startOverlay); });
  btnPause.addEventListener('click',()=>{ if(!state.running) resume(); else togglePause(); }); btnSound.addEventListener('click',()=>{ const on = AudioKit.toggle(); btnSound.textContent = on ? '🔊' : '🔇'; if(on) AudioKit.tick(); });
  btnSettings.addEventListener('click',()=>{ rangeSpeed.value=opts.speedMul; chkShake.checked=opts.shake; chkCB.checked=opts.colorblind; chkEco.checked=opts.eco; chkHints.checked=opts.hints; dlgSettings.showModal(); });
  document.getElementById('btnSaveSettings').addEventListener('click',(e)=>{ e.preventDefault(); opts.speedMul=parseFloat(rangeSpeed.value); opts.shake=chkShake.checked; opts.colorblind=chkCB.checked; opts.eco=chkEco.checked; opts.hints=chkHints.checked; storage.set(SAVE.opts, opts); dlgSettings.close(); toast('設定を保存しました'); });

  // Shop & Skins
  const dlgShop=document.getElementById('dlgShop'); const shopList=document.getElementById('shopList');
  function buildShop(){ shopList.innerHTML=''; const items=[
    {key:'startShield', name:'開始時シールド', desc:'+0.4〜のシールドを付与', base:40, max:3},
    {key:'magnetDur', name:'マグネット延長', desc:'+0.5s/レベル', base:30, max:5},
    {key:'dashCD', name:'ダッシュCD短縮', desc:'-0.4s/レベル', base:45, max:4},
    {key:'scoreMul', name:'スコア倍率', desc:'+10%/レベル', base:60, max:5},
    {key:'extraLife', name:'エクストラライフ', desc:'1回だけミスを無効化', base:120, max:1},
  ]; items.forEach(it=>{ const lv=uLevel(it.key); const cost = Math.floor(it.base * Math.pow(1.6, lv)); const can = lv<it.max && wallet.gems>=cost; const row=document.createElement('div'); row.className='shop-item'; row.innerHTML=`<div><strong>${it.name}</strong> <span class="chip">Lv.${lv}/${it.max}</span><div class="muted" style="font-size:.85rem">${it.desc}</div></div><button class="btn ${can?'primary':''}" ${can?'':'disabled'}>${lv>=it.max?'MAX':`購入 💎${cost}`}</button>`; row.querySelector('button').onclick=()=>{ if(lv>=it.max) return; if(wallet.gems<cost){ toast('💎不足'); return; } addGems(-cost); upgrades[it.key]=(upgrades[it.key]||0)+1; saveUpgrades(); toast(`${it.name} Lv.${upgrades[it.key]}`); }; shopList.appendChild(row); }); }
  const dlgSkins=document.getElementById('dlgSkins'); const skinList=document.getElementById('skinList');
  function buildSkins(){ skinList.innerHTML=''; skins.forEach(s=>{ const owned = (s.cost===0) || storage.get('skin-'+s.id, false); const can = wallet.gems>=s.cost && !owned; const row=document.createElement('div'); row.className='shop-item'; row.innerHTML=`<div><strong>${s.name}</strong> <span class="chip" style="background:${s.color}; color:#000">●</span> ${s.cost?`<span class='muted'>/ 💎${s.cost}</span>`:'<span class="muted">/ Free</span>'}</div><div><button class="btn ${owned?'':'primary'}" data-id="${s.id}">${owned?(currentSkin===s.id?'使用中':'使用'):('購入')}</button></div>`; row.querySelector('button').onclick=()=>{ if(!owned){ if(wallet.gems<s.cost){ toast('💎不足'); return; } addGems(-s.cost); storage.set('skin-'+s.id,true); } currentSkin=s.id; storage.set(SAVE.skin, currentSkin); buildSkins(); toast(`${s.name} を装備`); }; skinList.appendChild(row); }); }
  btnShop.addEventListener('click',()=>{ buildShop(); dlgShop.showModal(); }); btnSkins.addEventListener('click',()=>{ buildSkins(); dlgSkins.showModal(); }); btnBossGo.addEventListener('click',()=>{ startBoss(); });

  function togglePause(){ if(!state.running || state.over) return; state.running=false; btnPause.textContent='▶'; toast('Pause'); }
  function resume(){ if(state.over) return; state.running=true; btnPause.textContent='⏸'; toast('Resume'); }

  // Missions
  function generateMissions(){ const pool=[
    {id:'c80', text:'コインを80枚集める', test: s=>s.coins>=80},
    {id:'dash4', text:'1プレイでダッシュを4回', test: s=>s.dash>=4},
    {id:'combo35', text:'コンボ倍率3.5達成', test: s=>s.maxCombo>=3.5},
    {id:'shield', text:'シールド取得', test: s=>s.shield>0},
    {id:'score1200', text:'スコア1200到達', test: s=>s.score>=1200},
  ]; const chosen=[]; while(chosen.length<3){ const m=choice(pool); if(!chosen.find(c=>c.id===m.id)) chosen.push(m);} return chosen; }
  let missions = storage.get(SAVE.missions, null); if(!missions){ missions=generateMissions(); storage.set(SAVE.missions, missions);} missionsEl.innerHTML = '<strong>本日のミッション</strong><br>• '+missions.map(m=>m.text).join('<br>• ');
  function summarizeMissions(){ const done = missions.filter(m=>m.test(stats)).map(m=>m.text); return (done.length? done.join(' / ') : 'なし'); }

  // Init
  state.best = storage.get(SAVE.best, 0); walletEl.textContent=wallet.gems; updateUI(); reset(); show(startOverlay);

  </script>
</body>
</html>