🎍【コピペで完成】ブラウザで動く「お年玉ガチャ抽選会」を作ってみた(ハンドル演出+ワクワク間つき)

お正月っぽい “お年玉抽選会” を、ブラウザだけで動くガチャとして作ってみました🎰✨
アプリのガチャ風に、

  • 1等〜4等の確率ガチャ
  • 当たりごとに演出が変わる(紙吹雪・キラキラ・バースト・揺れ)
  • ガチャガチャのハンドルを回すイメージ
  • 判定が出るまでの「間」=ワクワク感(ドラムロール風の待機演出)

…を全部入れています!


✅ できること(完成イメージ)

🎯 景品(例)

  • 1等:10,000円
  • 2等:5,000円
  • 3等:2,000円
  • 4等:500円

🎰 ガチャ演出

  • ハンドルを回すアニメ(クリックでも開始できる)
  • ガラス玉が回る → ティッカーが変化 → ドラムロール → カプセル落下っぽい音 → 結果表示
    みたいな「待ち」を入れて、早すぎて味気ない問題を解消しています🔥

📌 実装の特徴

  • HTML1ファイルだけ(サーバ不要でも動く)
  • 確率はスライダーで調整(合計100%チェックあり)
  • 乱数は crypto.getRandomValues() を使って偏りにくく

🚀 使い方(超かんたん)

  1. メモ帳などで新規ファイルを作る
  2. ファイル名を otoshidama_gacha.html にする
  3. 下のコードを 全部コピペ
  4. ダブルクリックでブラウザ起動 → 完成! 🎉

🎛 カスタマイズ(ここだけ触ればOK)

① 景品や金額を変えたい

JS内のここ👇(配列)を編集するだけです。

const prizes = [
  { key:"1", name:"1等", amount:10000, prob: 1,  theme:"gold"  },
  { key:"2", name:"2等", amount: 5000, prob: 6,  theme:"purple"},
  { key:"3", name:"3等", amount: 2000, prob: 23, theme:"blue"  },
  { key:"4", name:"4等", amount:  500, prob: 70, theme:"green" }
];
  • amount → 金額
  • prob → 当選確率(合計100にする)
  • name → 表示名(例:「特賞」などにも変更可)

② もっと “待ち時間” を伸ばしたい(ワクワクUP)

このへんで「間」を作っています👇(数字を少し大きくすると長くなる)

await tickSequence(820 + rand01()*420);

💡 小ネタ:演出の考え方(1等ほど派手にする)

  • 1等:紙吹雪+キラキラ多め+金色発光
  • 2等:キラキラ多め+紫系発光
  • 3等:バースト(粒が広がる)
  • 4等:揺れ(悔しい感じw)+小キラ

「当たった感」を演出で増やすと、体感がガラッと変わります😆


🧩 完成コード(コピペ用)

長いので折りたたみ。開いて全部コピペしてください👇

<details> <summary><strong>✅ ここをクリックでコード表示(全部コピーOK)</strong></summary>

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>お年玉ガチャ抽選会</title>
  <style>
    :root{
      --bg1:#0b1020; --bg2:#1a0b2a;
      --text:#e8eefc; --muted:#a7b3d6;
      --ring: 0 0 0 3px rgba(124,58,237,.25);
      --panel: rgba(15,23,42,.72);
      --border: rgba(255,255,255,.10);
    }
    *{box-sizing:border-box}
    body{
      margin:0;
      font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Yu Gothic", Arial, sans-serif;
      color:var(--text);
      min-height:100vh;
      background:
        radial-gradient(1200px 600px at 20% 10%, rgba(124,58,237,.35), transparent 60%),
        radial-gradient(900px 500px at 80% 20%, rgba(34,197,94,.25), transparent 55%),
        radial-gradient(800px 500px at 50% 90%, rgba(239,68,68,.18), transparent 55%),
        linear-gradient(180deg, var(--bg1), var(--bg2));
      overflow-x:hidden;
    }
    .wrap{ max-width:980px; margin:0 auto; padding:24px 16px 40px; }
    header{ display:flex; justify-content:space-between; align-items:flex-start; gap:12px; margin-bottom:16px; }
    h1{ font-size: clamp(20px, 2.6vw, 30px); margin:0; letter-spacing:.02em; }
    .subtitle{ margin-top:6px; color:var(--muted); font-size:14px; line-height:1.5; }
    .grid{ display:grid; grid-template-columns: 1.05fr .95fr; gap:14px; }
    @media (max-width: 860px){ .grid{grid-template-columns:1fr} }

    .card{
      background: var(--panel);
      border:1px solid var(--border);
      border-radius:18px;
      box-shadow: 0 18px 40px rgba(0,0,0,.35);
      overflow:hidden;
    }
    .card .hd{
      padding:14px 14px 0;
      display:flex; justify-content:space-between; align-items:center; gap:10px;
    }
    .badge{
      font-size:12px; padding:6px 10px; border-radius:999px;
      border:1px solid rgba(255,255,255,.14);
      color:var(--muted); background: rgba(255,255,255,.04);
      white-space:nowrap;
    }
    .body{ padding:14px; }

    .stage{
      position:relative;
      min-height:330px;
      border-radius:16px;
      background:
        radial-gradient(700px 280px at 50% 0%, rgba(255,255,255,.12), transparent 60%),
        linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
      border:1px solid rgba(255,255,255,.10);
      overflow:hidden;
      display:flex;
      flex-direction:column;
      gap:12px;
      align-items:center;
      justify-content:center;
      padding:16px;
    }
    .stage::after{
      content:"";
      position:absolute; inset:-2px;
      background: radial-gradient(600px 220px at 50% 0%, rgba(124,58,237,.25), transparent 65%);
      opacity:.8;
      pointer-events:none;
      filter: blur(8px);
      transform: translateZ(0);
    }

    .fxLayer{ position:absolute; inset:0; z-index:5; pointer-events:none; overflow:hidden; }

    .resultBox{
      position:relative; z-index:2;
      width:min(560px, 100%);
      text-align:center;
      padding:16px 14px;
      border-radius:16px;
      background: rgba(2,6,23,.55);
      border:1px solid rgba(255,255,255,.12);
      backdrop-filter: blur(10px);
      transition: transform .2s ease, box-shadow .2s ease;
    }
    .resultTitle{ font-size:14px; color:var(--muted); margin:0 0 8px; letter-spacing:.12em; text-transform:uppercase; }
    .rank{ font-weight:900; font-size: clamp(26px, 4vw, 42px); margin:0; line-height:1.1; }
    .amount{ margin:10px 0 0; font-size: clamp(20px, 3.2vw, 34px); font-weight:900; }
    .hint{ margin:10px 0 0; color:var(--muted); font-size:13px; }

    .btnRow{ display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; justify-content:center; }
    button{
      appearance:none; border:none; cursor:pointer;
      border-radius:14px; padding:12px 14px;
      font-weight:900; color:var(--text);
      background: linear-gradient(135deg, rgba(124,58,237,.95), rgba(34,197,94,.85));
      box-shadow: 0 14px 30px rgba(0,0,0,.25);
      transition: transform .12s ease, filter .12s ease, box-shadow .12s ease;
      letter-spacing:.03em;
    }
    button:hover{ transform: translateY(-1px); filter: brightness(1.05); }
    button:active{ transform: translateY(1px); filter: brightness(.98); }
    button:focus-visible{ outline:none; box-shadow: var(--ring), 0 14px 30px rgba(0,0,0,.25); }
    button.secondary{
      background: rgba(255,255,255,.08);
      border:1px solid rgba(255,255,255,.14);
      box-shadow:none;
      color:var(--text);
    }
    button.secondary:hover{ background: rgba(255,255,255,.10); }
    button[disabled]{ opacity:.55; cursor:not-allowed; transform:none; filter:none; }

    .toggle{ display:flex; align-items:center; gap:8px; color:var(--muted); font-size:13px; user-select:none; cursor:pointer; }
    .toggle input{ transform: translateY(1px); }

    .rows{ display:flex; flex-direction:column; gap:12px; }
    .row{
      display:grid; grid-template-columns: 1fr auto;
      align-items:center; gap:10px;
      padding:10px 10px;
      border-radius:14px;
      border:1px solid rgba(255,255,255,.10);
      background: rgba(255,255,255,.04);
    }
    .row .left{ display:flex; flex-direction:column; gap:4px; }
    .row .label{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; font-weight:900; }
    .row small{ color:var(--muted); font-size:12px; line-height:1.4; }
    input[type="range"]{ width: 220px; }
    .pct{ font-variant-numeric: tabular-nums; color:var(--muted); font-weight:800; }
    .warn{ color:#fbbf24; font-size:12px; margin-top:10px; line-height:1.4; }
    .ok{ color:#86efac; font-size:12px; margin-top:10px; line-height:1.4; }

    .history{
      max-height: 210px;
      overflow:auto;
      border-radius:14px;
      border:1px solid rgba(255,255,255,.10);
      background: rgba(255,255,255,.03);
      padding:10px;
    }
    .histItem{
      display:flex; justify-content:space-between; gap:10px;
      padding:8px 8px;
      border-radius:12px;
      border:1px solid rgba(255,255,255,.08);
      background: rgba(2,6,23,.35);
      margin-bottom:8px;
      font-size:13px;
    }
    .histItem:last-child{ margin-bottom:0; }
    .histLeft{ display:flex; flex-direction:column; gap:2px; }
    .histRank{ font-weight:900; }
    .histTime{ color:var(--muted); font-size:12px; }
    .sumRow{
      display:flex; justify-content:space-between; gap:10px;
      margin-top:10px;
      padding:10px 10px;
      border-radius:14px;
      border:1px solid rgba(255,255,255,.10);
      background: rgba(255,255,255,.04);
      font-weight:900;
    }

    .gachaWrap{
      position:relative;
      z-index:3;
      width:min(560px, 100%);
      display:grid;
      grid-template-columns: 1fr 170px;
      gap:12px;
      align-items:center;
    }
    @media (max-width:520px){
      .gachaWrap{ grid-template-columns:1fr; }
    }
    .machine{
      position:relative;
      border-radius:16px;
      border:1px solid rgba(255,255,255,.10);
      background: rgba(255,255,255,.04);
      padding:12px;
      overflow:hidden;
    }
    .machine::before{
      content:"";
      position:absolute; inset:-2px;
      background: radial-gradient(380px 180px at 30% 10%, rgba(255,255,255,.10), transparent 60%);
      opacity:.9; pointer-events:none;
    }
    .machineInner{ position:relative; display:flex; align-items:center; justify-content:space-between; gap:10px; z-index:2; }
    .glass{
      width:84px; height:84px;
      border-radius:999px;
      border:1px solid rgba(255,255,255,.16);
      background: radial-gradient(circle at 30% 30%, rgba(255,255,255,.26), rgba(255,255,255,.06) 55%, rgba(2,6,23,.35) 100%);
      box-shadow: inset 0 0 30px rgba(255,255,255,.06);
      position:relative;
      overflow:hidden;
      flex:0 0 auto;
    }
    .balls{ position:absolute; inset:0; }
    .ball{
      position:absolute;
      width:14px; height:14px;
      border-radius:999px;
      background: rgba(255,255,255,.82);
      opacity:.85;
      filter: drop-shadow(0 3px 6px rgba(0,0,0,.25));
    }
    .spinning .ball{
      animation: swirl 650ms ease-in-out infinite;
    }
    @keyframes swirl{
      0%{ transform: translate(0,0) scale(1); opacity:.7;}
      50%{ transform: translate(var(--dx), var(--dy)) scale(1.15); opacity:1;}
      100%{ transform: translate(0,0) scale(1); opacity:.7;}
    }

    .slot{
      flex:1;
      min-height:56px;
      border-radius:14px;
      border:1px dashed rgba(255,255,255,.14);
      display:flex;
      align-items:center;
      justify-content:center;
      text-align:center;
      padding:8px;
      color:var(--muted);
      font-weight:900;
      letter-spacing:.08em;
      background: rgba(2,6,23,.20);
    }
    .slot .ticker{ display:inline-block; font-variant-numeric: tabular-nums; }
    .slot.rolling .ticker{ animation: ticker 120ms linear infinite; }
    @keyframes ticker{
      0%{ transform: translateY(0); opacity:.8; }
      50%{ transform: translateY(-2px); opacity:1; }
      100%{ transform: translateY(0); opacity:.8; }
    }

    .handlePanel{
      position:relative;
      border-radius:16px;
      border:1px solid rgba(255,255,255,.10);
      background: rgba(255,255,255,.04);
      padding:12px;
      display:flex;
      flex-direction:column;
      align-items:center;
      justify-content:center;
      gap:10px;
      overflow:hidden;
    }
    .handlePanel::before{
      content:"";
      position:absolute; inset:-2px;
      background: radial-gradient(220px 140px at 60% 20%, rgba(124,58,237,.18), transparent 70%);
      pointer-events:none;
    }

    .handle{
      position:relative;
      width:110px; height:110px;
      display:grid;
      place-items:center;
      z-index:2;
      user-select:none;
    }
    .hub{
      width:46px; height:46px;
      border-radius:999px;
      background: radial-gradient(circle at 30% 30%, rgba(255,255,255,.40), rgba(255,255,255,.08) 60%, rgba(2,6,23,.40));
      border:1px solid rgba(255,255,255,.18);
      box-shadow: inset 0 0 18px rgba(255,255,255,.08), 0 10px 22px rgba(0,0,0,.25);
      position:relative;
    }
    .arm{
      position:absolute;
      width:60px; height:10px;
      background: rgba(255,255,255,.14);
      border:1px solid rgba(255,255,255,.16);
      border-radius:999px;
      top:50%;
      left:50%;
      transform-origin: 8px 50%;
      transform: translate(-8px,-50%) rotate(0deg);
      box-shadow: 0 8px 18px rgba(0,0,0,.20);
    }
    .knob{
      position:absolute;
      width:18px; height:18px;
      border-radius:999px;
      right:-9px; top:50%;
      transform: translateY(-50%);
      background: radial-gradient(circle at 30% 30%, rgba(255,255,255,.55), rgba(255,255,255,.10));
      border:1px solid rgba(255,255,255,.18);
    }
    .turning .arm{ animation: turn 1.15s cubic-bezier(.2,.9,.2,1) both; }
    @keyframes turn{
      0%   { transform: translate(-8px,-50%) rotate(0deg); }
      25%  { transform: translate(-8px,-50%) rotate(110deg); }
      55%  { transform: translate(-8px,-50%) rotate(250deg); }
      100% { transform: translate(-8px,-50%) rotate(360deg); }
    }

    .handleHint{
      font-size:12px;
      color:var(--muted);
      z-index:2;
      text-align:center;
      line-height:1.35;
    }

    .veil{
      position:absolute; inset:0;
      background: linear-gradient(180deg, rgba(2,6,23,.05), rgba(2,6,23,.25));
      opacity:0;
      pointer-events:none;
      transition: opacity .2s ease;
      z-index:4;
    }
    .veil.on{ opacity:1; }

    .shake{ animation: shake .6s ease both; }
    @keyframes shake{
      0%,100%{ transform:translateX(0); }
      20%{ transform:translateX(-6px); }
      40%{ transform:translateX(6px); }
      60%{ transform:translateX(-4px); }
      80%{ transform:translateX(4px); }
    }
    .glow1{ animation: glow1 1.1s ease-in-out both; }
    @keyframes glow1{
      0%{ box-shadow: 0 0 0 rgba(255,255,255,0); transform: scale(.98); }
      30%{ box-shadow: 0 0 40px rgba(255,215,0,.35); transform: scale(1.02); }
      100%{ box-shadow: 0 0 18px rgba(255,215,0,.22); transform: scale(1); }
    }
    .glow2{ animation: glow2 .95s ease-in-out both; }
    @keyframes glow2{
      0%{ box-shadow: 0 0 0 rgba(255,255,255,0); transform: scale(.99); }
      35%{ box-shadow: 0 0 36px rgba(99,102,241,.28); transform: scale(1.015); }
      100%{ box-shadow: 0 0 16px rgba(99,102,241,.18); transform: scale(1); }
    }
    .pop{ animation: pop .55s cubic-bezier(.2,.9,.2,1) both; }
    @keyframes pop{
      0%{ transform: scale(.92); opacity:.4; }
      60%{ transform: scale(1.04); opacity:1; }
      100%{ transform: scale(1); }
    }

    .confetti{
      position:absolute; top:-12px;
      width:10px; height:18px;
      opacity:.95;
      border-radius:3px;
      transform: rotate(var(--rot));
      left: var(--x);
      animation: fall var(--dur) linear forwards;
      filter: drop-shadow(0 4px 8px rgba(0,0,0,.25));
      z-index:6;
    }
    @keyframes fall{
      to{ transform: translateY(120%) rotate(calc(var(--rot) + 220deg)); opacity:1; }
    }
    .spark{
      position:absolute;
      width:10px; height:10px;
      left: var(--x); top: var(--y);
      border-radius:999px;
      background: rgba(255,255,255,.9);
      box-shadow: 0 0 18px rgba(255,255,255,.6);
      animation: spark 0.75s ease-out forwards;
      z-index:6;
    }
    @keyframes spark{
      0%{ transform: scale(.2); opacity:0; }
      35%{ transform: scale(1.2); opacity:1; }
      100%{ transform: translateY(-30px) scale(.1); opacity:0; }
    }
    .burst{
      position:absolute;
      left:50%; top:50%;
      width:8px; height:8px;
      border-radius:999px;
      background: rgba(255,255,255,.85);
      transform: translate(-50%,-50%);
      animation: burst 650ms ease-out forwards;
      opacity:.95;
      z-index:6;
    }
    @keyframes burst{
      0%{ transform: translate(-50%,-50%) scale(.2); opacity:0; }
      25%{ opacity:1; }
      100%{ transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(.05); opacity:0; }
    }

    .chips{ display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; justify-content:center; }
    .chip{
      font-size:12px; color:var(--muted);
      border:1px solid rgba(255,255,255,.12);
      background: rgba(255,255,255,.04);
      padding:6px 10px; border-radius:999px;
    }
    .footerNote{ margin-top:10px; color:var(--muted); font-size:12px; line-height:1.5; }
  </style>
</head>
<body>
  <div class="wrap">
    <header>
      <div>
        <h1>🎍 お年玉ガチャ抽選会</h1>
        <div class="subtitle">ハンドルを回してガチャ感UP!判定までの“ワクワク時間”も追加済み。</div>
      </div>
      <div class="badge">1ファイル / HTML+CSS+JS</div>
    </header>

    <div class="grid">
      <section class="card">
        <div class="hd">
          <div class="badge">ガチャガチャ演出</div>
          <label class="toggle" title="音をON/OFF(初回はボタン押下で音が有効になります)">
            <input id="soundToggle" type="checkbox" checked />
            🔊 サウンド
          </label>
        </div>

        <div class="body">
          <div id="stage" class="stage" aria-live="polite">
            <div class="veil" id="veil"></div>
            <div class="fxLayer" id="fxLayer"></div>

            <div class="gachaWrap">
              <div class="machine" aria-hidden="true">
                <div class="machineInner">
                  <div class="glass" id="glass">
                    <div class="balls" id="balls"></div>
                  </div>
                  <div class="slot" id="slot">
                    <span class="ticker" id="ticker">READY</span>
                  </div>
                </div>
              </div>

              <div class="handlePanel">
                <div class="handle" id="handle">
                  <div class="hub"></div>
                  <div class="arm" id="arm"><div class="knob"></div></div>
                </div>
                <div class="handleHint" id="handleHint">ハンドルを回す<br>(=ガチャ開始)</div>
              </div>
            </div>

            <div id="resultBox" class="resultBox">
              <p class="resultTitle">RESULT</p>
              <p id="rankText" class="rank">未抽選</p>
              <p id="amountText" class="amount">—</p>
              <p id="hintText" class="hint">「ガチャを回す!」でハンドル演出→判定までワクワク✨</p>

              <div class="btnRow">
                <button id="rollBtn">ガチャを回す!</button>
                <button id="resetBtn" class="secondary">履歴リセット</button>
              </div>

              <div class="chips">
                <div class="chip">1等:10,000円</div>
                <div class="chip">2等:5,000円</div>
                <div class="chip">3等:2,000円</div>
                <div class="chip">4等:500円</div>
              </div>

              <div class="footerNote">
                ※ 乱数は <code>crypto.getRandomValues</code> を使用。<br>
                ※ 判定前に「回転→カプセル落下→ドラムロール」っぽい間を入れてます。
              </div>
            </div>
          </div>
        </div>
      </section>

      <aside class="card">
        <div class="hd">
          <div class="badge">確率ガチャ設定</div>
          <div class="badge" id="sumBadge">合計: 100%</div>
        </div>
        <div class="body">
          <div class="rows" id="probRows"></div>
          <div id="sumMsg" class="ok">OK:合計が100%です ✅</div>

          <div style="margin-top:14px;">
            <div class="badge">履歴</div>
          </div>
          <div class="history" id="history"></div>
          <div class="sumRow">
            <div>合計お年玉</div>
            <div id="sumAmount">0円</div>
          </div>
        </div>
      </aside>
    </div>
  </div>

<script>
(() => {
  const prizes = [
    { key:"1", name:"1等", amount:10000, prob: 1,  theme:"gold"  },
    { key:"2", name:"2等", amount: 5000, prob: 6,  theme:"purple"},
    { key:"3", name:"3等", amount: 2000, prob: 23, theme:"blue"  },
    { key:"4", name:"4等", amount:  500, prob: 70, theme:"green" }
  ];

  const stage = document.getElementById("stage");
  const fxLayer = document.getElementById("fxLayer");
  const veil = document.getElementById("veil");
  const resultBox = document.getElementById("resultBox");
  const rankText = document.getElementById("rankText");
  const amountText = document.getElementById("amountText");
  const hintText = document.getElementById("hintText");
  const rollBtn = document.getElementById("rollBtn");
  const resetBtn = document.getElementById("resetBtn");
  const probRows = document.getElementById("probRows");
  const sumBadge = document.getElementById("sumBadge");
  const sumMsg = document.getElementById("sumMsg");
  const historyEl = document.getElementById("history");
  const sumAmountEl = document.getElementById("sumAmount");
  const soundToggle = document.getElementById("soundToggle");

  const handle = document.getElementById("handle");
  const handleHint = document.getElementById("handleHint");
  const balls = document.getElementById("balls");
  const slot = document.getElementById("slot");
  const ticker = document.getElementById("ticker");

  let rolling = false;
  let history = [];
  let total = 0;

  const fmtYen = (n) => n.toLocaleString("ja-JP") + "円";
  const nowTime = () => new Date().toLocaleTimeString("ja-JP", {hour:"2-digit", minute:"2-digit", second:"2-digit"});
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  function rand01(){
    const u = new Uint32Array(1);
    crypto.getRandomValues(u);
    return u[0] / 4294967296;
  }
  function sumProb(){ return prizes.reduce((a,p)=>a + Number(p.prob || 0), 0); }
  function updateSumUI(){
    const s = sumProb();
    sumBadge.textContent = `合計: ${s}%`;
    const ok = (s === 100);
    sumMsg.className = ok ? "ok" : "warn";
    sumMsg.textContent = ok ? "OK:合計が100%です ✅" : `注意:合計が100%になるように調整してね(今 ${s}%)⚠️`;
    rollBtn.disabled = rolling || !ok;
  }
  function drawPrize(){
    const r = rand01() * 100;
    let acc = 0;
    for(const p of prizes){
      acc += Number(p.prob);
      if(r < acc) return p;
    }
    return prizes[prizes.length - 1];
  }

  let audioCtx = null;
  function ensureAudio(){
    if(!soundToggle.checked) return null;
    if(!audioCtx){
      const AC = window.AudioContext || window.webkitAudioContext;
      if(!AC) return null;
      audioCtx = new AC();
    }
    if(audioCtx.state === "suspended"){
      audioCtx.resume().catch(()=>{});
    }
    return audioCtx;
  }
  function beep({freq=440, dur=0.10, type="sine", gain=0.06}={}){
    const ctx = ensureAudio();
    if(!ctx) return;
    const o = ctx.createOscillator();
    const g = ctx.createGain();
    o.type = type;
    o.frequency.value = freq;
    g.gain.value = 0.0001;
    o.connect(g); g.connect(ctx.destination);
    const t = ctx.currentTime;
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(gain, t + 0.01);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.start(t);
    o.stop(t + dur + 0.02);
  }
  async function tickSequence(msTotal=900){
    const steps = Math.max(8, Math.floor(msTotal / 70));
    for(let i=0;i<steps;i++){
      const f = 280 + i*18 + (rand01()*18);
      beep({freq:f, dur:0.03, type:"sine", gain:0.03});
      await sleep(55 + rand01()*25);
    }
  }
  function fanfareFor(prize){
    if(!soundToggle.checked) return;
    if(prize.key === "1"){
      beep({freq:523.25, dur:0.09, type:"triangle", gain:0.07});
      setTimeout(()=>beep({freq:659.25, dur:0.10, type:"triangle", gain:0.07}), 110);
      setTimeout(()=>beep({freq:783.99, dur:0.14, type:"triangle", gain:0.08}), 240);
    }else if(prize.key === "2"){
      beep({freq:523.25, dur:0.09, type:"sine", gain:0.06});
      setTimeout(()=>beep({freq:659.25, dur:0.10, type:"sine", gain:0.06}), 120);
    }else if(prize.key === "3"){
      beep({freq:440, dur:0.07, type:"square", gain:0.045});
      setTimeout(()=>beep({freq:554.37, dur:0.08, type:"square", gain:0.045}), 90);
    }else{
      beep({freq:392, dur:0.06, type:"sine", gain:0.035});
    }
  }

  function clearFX(){
    fxLayer.innerHTML = "";
    stage.classList.remove("shake");
    resultBox.classList.remove("glow1","glow2","pop");
    veil.classList.remove("on");
    balls.classList.remove("spinning");
    slot.classList.remove("rolling");
    handle.classList.remove("turning");
  }

  function confetti(count=90){
    const colors = ["#fde047","#f97316","#a78bfa","#60a5fa","#34d399","#f43f5e","#ffffff"];
    for(let i=0;i<count;i++){
      const el = document.createElement("div");
      el.className = "confetti";
      el.style.setProperty("--x", Math.floor(rand01()*100) + "%");
      el.style.setProperty("--rot", Math.floor(rand01()*360) + "deg");
      el.style.setProperty("--dur", (1.2 + rand01()*0.9).toFixed(2) + "s");
      el.style.background = colors[Math.floor(rand01()*colors.length)];
      el.style.width = (8 + rand01()*8) + "px";
      el.style.height = (10 + rand01()*14) + "px";
      el.style.opacity = (0.75 + rand01()*0.25).toFixed(2);
      fxLayer.appendChild(el);
      setTimeout(()=>el.remove(), 2400);
    }
  }
  function sparkles(count=36){
    for(let i=0;i<count;i++){
      const s = document.createElement("div");
      s.className = "spark";
      s.style.setProperty("--x", (10 + rand01()*80) + "%");
      s.style.setProperty("--y", (15 + rand01()*70) + "%");
      fxLayer.appendChild(s);
      setTimeout(()=>s.remove(), 900);
    }
  }
  function burst(count=18){
    for(let i=0;i<count;i++){
      const b = document.createElement("div");
      b.className = "burst";
      const ang = rand01() * Math.PI * 2;
      const dist = 80 + rand01()*90;
      b.style.setProperty("--dx", Math.cos(ang) * dist + "px");
      b.style.setProperty("--dy", Math.sin(ang) * dist + "px");
      b.style.background = `rgba(255,255,255,${0.65 + rand01()*0.3})`;
      fxLayer.appendChild(b);
      setTimeout(()=>b.remove(), 850);
    }
  }

  function applyTheme(prize){
    const themeMap = {
      gold:   ["#fde047", "rgba(253,224,71,.18)"],
      purple: ["#a78bfa", "rgba(167,139,250,.16)"],
      blue:   ["#60a5fa", "rgba(96,165,250,.15)"],
      green:  ["#34d399", "rgba(52,211,153,.14)"],
    };
    const [c, glow] = themeMap[prize.theme] || ["#fff","rgba(255,255,255,.10)"];
    rankText.style.color = c;
    amountText.style.color = c;
    resultBox.style.boxShadow = `0 0 0 1px rgba(255,255,255,.08), 0 0 48px ${glow}`;
  }
  function playFX(prize){
    applyTheme(prize);
    if(prize.key === "1"){
      resultBox.classList.add("glow1");
      confetti(100);
      setTimeout(()=>sparkles(24), 120);
      setTimeout(()=>sparkles(24), 320);
    }else if(prize.key === "2"){
      resultBox.classList.add("glow2");
      sparkles(44);
      setTimeout(()=>sparkles(28), 260);
    }else if(prize.key === "3"){
      resultBox.classList.add("pop");
      burst(26);
    }else{
      stage.classList.add("shake");
      setTimeout(()=>stage.classList.remove("shake"), 650);
      sparkles(10);
    }
  }

  function buildBalls(){
    balls.innerHTML = "";
    const spots = [[18,18],[50,14],[62,30],[30,52],[58,58],[20,62],[40,38],[70,48]];
    for(let i=0;i<spots.length;i++){
      const d = document.createElement("div");
      d.className = "ball";
      d.style.left = spots[i][0] + "%";
      d.style.top  = spots[i][1] + "%";
      d.style.setProperty("--dx", (Math.floor(-10 + rand01()*20)) + "px");
      d.style.setProperty("--dy", (Math.floor(-10 + rand01()*20)) + "px");
      d.style.background = `rgba(255,255,255,${0.65 + rand01()*0.28})`;
      balls.appendChild(d);
    }
  }

  const tickWords = ["READY","SPIN...","GACHA!","??","...","!!!","ROLL"];
  function startTicker(){
    slot.classList.add("rolling");
    let i=0;
    ticker.textContent = "SPIN...";
    const id = setInterval(()=>{
      ticker.textContent = tickWords[i % tickWords.length];
      i++;
    }, 120);
    return () => { clearInterval(id); slot.classList.remove("rolling"); };
  }

  function buildProbRows(){
    probRows.innerHTML = "";
    prizes.forEach((p) => {
      const row = document.createElement("div");
      row.className = "row";
      row.innerHTML = `
        <div class="left">
          <div class="label">
            <span>${p.name}(${fmtYen(p.amount)})</span>
            <span class="pct" id="pct-${p.key}">${p.prob}%</span>
          </div>
          <small>確率を調整(合計100%にしてね)</small>
        </div>
        <div>
          <input id="rng-${p.key}" type="range" min="0" max="100" step="1" value="${p.prob}" aria-label="${p.name}の確率" />
        </div>
      `;
      probRows.appendChild(row);

      const rng = row.querySelector(`#rng-${p.key}`);
      const pct = row.querySelector(`#pct-${p.key}`);

      rng.addEventListener("input", () => {
        p.prob = Number(rng.value);
        pct.textContent = `${p.prob}%`;
        updateSumUI();
      });
    });
    updateSumUI();
  }

  function renderHistory(){
    historyEl.innerHTML = "";
    if(history.length === 0){
      const empty = document.createElement("div");
      empty.style.color = "var(--muted)";
      empty.style.fontSize = "13px";
      empty.style.padding = "10px 6px";
      empty.textContent = "まだ履歴はありません。";
      historyEl.appendChild(empty);
    }else{
      for(const h of history.slice().reverse()){
        const div = document.createElement("div");
        div.className = "histItem";
        div.innerHTML = `
          <div class="histLeft">
            <div class="histRank">${h.prize.name}</div>
            <div class="histTime">${h.t}</div>
          </div>
          <div style="font-weight:900;">${fmtYen(h.prize.amount)}</div>
        `;
        historyEl.appendChild(div);
      }
    }
    sumAmountEl.textContent = fmtYen(total);
  }

  function resetAll(){
    history = [];
    total = 0;
    rankText.textContent = "未抽選";
    amountText.textContent = "—";
    hintText.textContent = "「ガチャを回す!」でハンドル演出→判定までワクワク✨";
    rankText.style.color = "";
    amountText.style.color = "";
    resultBox.style.boxShadow = "";
    ticker.textContent = "READY";
    clearFX();
    renderHistory();
  }

  async function roll(){
    if(rolling) return;
    if(sumProb() !== 100) return;

    rolling = true;
    rollBtn.disabled = true;
    resetBtn.disabled = true;
    handleHint.textContent = "回転中…";
    veil.classList.add("on");

    clearFX();
    buildBalls();

    hintText.textContent = "ハンドルを回して…いくよ…!🌀";
    rankText.textContent = "抽選中…";
    amountText.textContent = "…";

    handle.classList.add("turning");
    balls.classList.add("spinning");
    const stopTicker = startTicker();

    for(let i=0;i<6;i++){
      beep({freq: 420 + i*25, dur:0.03, type:"square", gain:0.03});
      await sleep(120);
    }

    hintText.textContent = "カプセルが出てくる…!✨";
    await tickSequence(820 + rand01()*420);

    beep({freq: 180, dur:0.08, type:"sine", gain:0.04});
    await sleep(220);

    const prize = drawPrize();

    stopTicker();
    balls.classList.remove("spinning");
    handle.classList.remove("turning");

    rankText.textContent = prize.name;
    amountText.textContent = fmtYen(prize.amount);

    const msgMap = {
      "1": "🎉 大当たり!!金ピカ演出いきます!!",
      "2": "✨ ナイス!いいお年玉!",
      "3": "🙂 ちょうど嬉しいライン!",
      "4": "😆 参加賞!次いこ次!",
    };
    hintText.textContent = msgMap[prize.key] || "結果が出ました!";

    playFX(prize);
    fanfareFor(prize);

    history.push({ t: nowTime(), prize });
    total += prize.amount;
    renderHistory();

    await sleep(950);
    veil.classList.remove("on");
    handleHint.textContent = "もう一回回す?";
    rolling = false;
    resetBtn.disabled = false;
    updateSumUI();
  }

  rollBtn.addEventListener("click", roll);
  handle.addEventListener("click", roll);
  resetBtn.addEventListener("click", resetAll);
  document.addEventListener("pointerdown", () => ensureAudio(), { once:true });

  buildProbRows();
  buildBalls();
  renderHistory();
  updateSumUI();
})();
</script>
</body>
</html>

</details>


❓ FAQ(よくあるつまずき)

Q1. クリックしても音が鳴らない…

ブラウザによっては「初回クリックで音が解禁」されます。
このコードは最初の操作で AudioContext を起動するようにしてあるので、だいたい解決します🔊

Q2. 確率を変えたら回せなくなった

合計が100%じゃないと回せない仕様です!
右上の「合計」を見て 100% に調整してください✅

Q3. もっと“ガチャっぽい”見た目にしたい

CSSの .machine.handlePanel の背景色を変えるだけで雰囲気が変わります。
(例:赤・金に寄せると一気に正月っぽく🎍✨)


🧠 まとめ:ガチャは「間」と「演出」で体感が決まる!

正直、抽選だけなら一瞬で終わります。
でも、**「回してる感」+「待ち」+「当たりの演出差」**を入れると、同じ抽選でもワクワクが段違いになります🎰🔥

次は、もしやるなら👇も面白いです!

  • 10連ガチャ(演出をまとめてド派手に)
  • “残念演出”をもっと作り込む(4等で画面暗転→復活など)
  • 当選結果の集計グラフ(回数ごとの偏りチェック)

必要なら、**「10連対応版」**に改造したコードもそのまま作れます。

あざらし

はじめまして、あざらしです。 フリーターからエンジニア会社へ就職し、 現在はフリーランスのシステムエンジニアとして働いています。 本業のエンジニア業のかたわら、 ✍️ ブログ運営 と「収入の柱を増やす挑戦」を少しずつ続けています。 フリーター時代から比べると、 段階的に収入が増えていくのを実感できるのが素直にうれしい今日この頃。 このブログでは、日々の気づき・体験談 IT・ガジェット・ゲーム系の話 「調べて分かったこと」を噛み砕いた解説 などを中心に、ジャンルに縛られない雑記ブログとして発信しています。 「自分と同じように悩んでいる人のヒントになればいいな」 そんな気持ちで更新中です。 👉 プロフィール詳細は、名前「あざらし」をクリックしてください

Recent Posts