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>

投稿者: chosuke

趣味はゲームやアニメや漫画などです

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です