当サイトはアフィリエイト広告(PR)を利用しています

作り方

変化球と魔球の作り方|消える・止まる・分身する球をJavaScriptで作る【熱闘天敵バスターズ】

変化球と魔球の作り方|消える・止まる・分身する球をJavaScriptで作る【熱闘天敵バスターズ】

この記事の結論

  • 球の位置は「進み具合 t」で決める。t × t で奥行きが出る
  • 変化球は sin(π × t) × t を足すだけ。最後はかならずねらった場所にとどく
  • 魔球は「t の進み方」か「描き方」を変えるだけで作れる
  • 球種の出やすさはデータにしておくと、むずかしさの調整がかんたん

バッティングゲームで一番おもしろいのは、どんな球が飛んでくるかです。まっすぐ、曲がる、落ちる…そして、消えたり止まったり分身したりする「魔球」。

この記事では、僕が AI の Claude と一緒に作った熱闘天敵バスターズの、変化球と魔球のしくみを実際のコードで解説します。

まずは見てみよう:球の軌道

ボタンを押すと、その球がピッチャーからストライクゾーンまでどう飛んでくるかが見られます。うすい点は、球が通ったあとです。

「消える」はとちゅうで点が途切れ、「止まる」は同じ場所に点が重なり、「瞬間移動」はとちゅうで点が飛びます。

しくみ1:球の位置は「進み具合 t」で決める

球の動きはすべて、進み具合 t(0=ピッチャーの手、1=ストライクゾーン)をもとに計算しています。ふつうの球なら、t は「経過時間 ÷ とどくまでの時間」です。

そして、t から画面の上の位置を計算するのが次の関数です。

// 進み具合 t から、画面の上の位置と大きさを計算する
// dx:分身用に、とどく場所を左右にずらす量
function ballAt(p, t, dx = 0) {
  if (p.type === "warp" && t < 0.68) {
    dx += p.amp; // とちゅうまでは、ちがう場所に向かっている
  }
  // t × t:遠くはゆっくり、近づくほど速く見える(奥行きの表現)
  const e = t * t;
  let x = RELEASE.x + (p.target.x + dx - RELEASE.x) * e;
  let y = RELEASE.y + (p.target.y - RELEASE.y) * e;
  // とちゅうでふくらんで、最後はちゃんとねらった場所にとどく曲がり方
  const bend = Math.sin(Math.PI * Math.min(t, 1)) * t;
  if (p.type === "curve") x += p.amp * bend;
  if (p.type === "sinker") y -= p.amp * bend;
  if (p.type === "rise") y += p.amp * bend;
  if (p.type === "zigzag") x += p.amp * 2 * Math.sin(t * Math.PI * 7) * Math.max(0, 1 - t);
  const visible = !(p.type === "vanish" && t > 0.4 && t < 0.86);
  return { x, y, r: 2.5 + 10.5 * e, visible };
}

// ===== 案内用の画面(overlay) =====
function showOverlay({ title, text, button, onClick, sub = null, onSub = null, kakapo = true, stages = false, share = false }) {
  $("overlayTitle").innerHTML = title;
  $("overlayText").innerHTML = text;
  // svg は .hidden が効かないので、hidden 属性を直接つけ外しする
  $("overlayKakapo").toggleAttribute("hidden", !kakapo);
  $("overlayButton").hidden = !button;
  $("overlayButton").textContent = button || "";
  $("overlayButton").onclick = onClick || null;
  $("subButton").hidden = !sub;
  $("subButton").textContent = sub || "";
  $("subButton").onclick = onSub;
  $("stageList").hidden = !stages;
  $("share").hidden = !share;
  if (stages) {
    renderStageList();
  }
  $("overlay").hidden = false;
}

function hideOverlay() {
  $("overlay").hidden = true;
}

// ステージを選ぶボタンを並べる(まだ遊べないステージは「???」)
function renderStageList() {
  const list = $("stageList");
  list.innerHTML = "";
  STAGES.forEach((stage, i) => {
    const button = document.createElement("button");
    button.type = "button";
    button.className = "stage-button";
    const open = i < save.unlocked;
    button.disabled = !open;
    button.innerHTML = "<span>" + (i + 1) + "</span>" + (open ? stage.name : "???");
    button.addEventListener("click", () => showIntro(i));
    list.appendChild(button);
  });
}

function showTitle() {
  state = "title";
  pitch = null;
  flight = null;
  message = null;
  showOverlay({
    title: "熱闘<br>天敵バスターズ",
    text: "カカポの里に、天敵たちが攻めてきた!<br>ホームランを打って、追い返そう。",
    stages: true,
    kakapo: false, // ステージのボタンが入りきるように(カカポは後ろの画面にいる)
  });
}

// ステージの紹介(天敵のセリフ)
function showIntro(n) {
  stageNum = n;
  pitchCount = 0;
  homeruns = 0;
  stageBest = 0;
  pitch = null;
  flight = null;
  message = null;
  enemyHurt = 0;
  state = "intro";
  updateHud();
  const stage = STAGES[n];
  showOverlay({
    title: "ステージ " + (n + 1),
    text:
      "<strong>" + stage.name + "</strong>が あらわれた!<br>" +
      "「" + stage.line + "」<br><br>" +
      PITCHES + "球のうち、<strong>ホームラン " + stage.need + "本</strong>で追い返せ!",
    button: "プレイボール",
    onClick: startStage,
    sub: "ステージを選ぶ",
    onSub: showTitle,
    kakapo: false,
  });
}

function startStage() {
  hideOverlay();
  nextPitch();
}

// 次の球を投げる準備
function nextPitch() {
  pitch = makePitch();
  elapsed = 0;
  swung = false;
  swingTime = 99;
  pendingHit = null;
  flight = null;
  message = null;
  state = "windup";
  timer = rand(0.8, 1.3); // 投げるまでの間(毎回少し変わる)
}

奥行きは「t × t」で出す

ポイントは、t をそのまま使わずに t × t(e)で位置を決めているところです。t × t は、最初はゆっくり増えて、最後に一気に増えます。そのため、遠くではゆっくり、近づくほど速く見えて、球がこちらに飛んでくる奥行きが出ます。球の大きさ(r)も同じように大きくしています。

しくみ2:変化球は「sin(π × t) × t」を足すだけ

カーブや落ちる球は、まっすぐの位置に曲がる量を足して作っています。

const bend = Math.sin(Math.PI * Math.min(t, 1)) * t;

sin(π × t) は、t が 0 のときと 1 のときに 0 になり、そのあいだでふくらむ形です。これに t を掛けると、ピッチャーの近くでは小さく、とちゅうで大きくふくらんで、ストライクゾーンにとどくときにはかならず 0 にもどります。

  • 横に足すとカーブ
  • 上に引くと落ちる球(画面の y は下に行くほど大きいので、引くと上にふくらんでから落ちて見える)
  • 下に足すと浮き上がる球

どれだけ曲がっても、最後はねらった場所にとどくのが大事なところです。こうしておくと、バットに当たったかどうかは「とどく場所」と「タイミング」だけを見ればよくなり、当たり判定がとてもシンプルになります。

曲がる大きさ(amp)は、球を作るときに球種ごとに決めています。

  if (type === "fast") p.dur *= 0.75;
  if (type === "slow") p.dur *= 1.6;
  if (type === "accel") p.dur *= 1.4;
  if (type === "curve") p.amp = side() * rand(90, 140);
  if (type === "sinker") p.amp = rand(90, 130);
  if (type === "rise") p.amp = rand(70, 100);
  if (type === "zigzag") p.amp = rand(40, 60);
  if (type === "warp") p.amp = side() * rand(80, 110);
  if (type === "stop") p.pause = rand(0.35, 0.7);
  if (type === "clone") p.fakes = [-1, 1].map((s) => s * rand(70, 100));

しくみ3:魔球は「時間」と「見た目」をいじるだけ

消える・止まる・分身する…と聞くと難しそうですが、どれも「時間の進み方を変える」か「描き方を変える」だけで作っています。

時間をいじる魔球:止まる・急加速

// 経過時間から、球がどこまで進んだか(0=ピッチャーの手、1=ストライクゾーン)
function progressOf(p, time) {
  if (p.type === "stop") {
    const half = p.dur * 0.5;
    if (time < half) return time / p.dur;
    if (time < half + p.pause) return 0.5; // 空中でぴたっと止まる
    return (time - p.pause) / p.dur;
  }
  if (p.type === "accel") {
    const u = time / p.dur;
    return u < 1 ? Math.pow(u, 2.4) : u; // ゆっくり → 急に速く
  }
  return time / p.dur;
}
  • 止まる球:半分まで来たら、しばらく t を 0.5 のまま止める。そのあと残りを進む
  • 急加速:t を「時間の 2.4 乗」にする。最初はほとんど進まず、最後に一気に来る

どちらも、球の位置の計算(ballAt)にはまったく手を入れていません。t の進み方を変えるだけで、まったくちがう球になります。

見た目をいじる魔球:消える・瞬間移動・ジグザグ・分身

  • 消える球:t が 0.4〜0.86 のあいだだけ、描かない(visible を false にする)。球はちゃんと飛んでいるので、タイミングは合わせられる
  • 瞬間移動:t が 0.68 になるまでは、横にずれた場所に向かって飛ばし、そこから本当の場所にパッと切りかえる
  • ジグザグ:sin(t × π × 7) で左右にゆらし、ゆれる幅を 1 − t で小さくしていく。最後はまっすぐ本当の場所に来る
  • 分身:左右にずらした場所にとどくニセモノの球を、本物と一緒に描く。t が 0.72 をこえたら、ニセモノだけをすっと消す

しくみ4:ステージごとの球種は「データ」で決める

どの天敵がどの球をどれくらい投げてくるかは、stages.js にデータとしてまとめています。たとえばステージ4のオコジョは、こうです。

  {
    name: "オコジョのシロガネ",
    animal: "stoat",
    line: "この白い毛は雪の色…わたしの球も、雪のように消えるのさ",
    need: 5,
    speed: [0.8, 0.9],
    pitches: { fast: 1, curve: 1, vanish: 3, stop: 2 },
    sky: ["#7d6a9e", "#e3b9a4"],
  }

pitches の数字が大きい球ほど、よく投げてきます。この表から球種を1つ選ぶのが、次の関数です。

// { straight: 2, curve: 1 } のような表から、数字の大きさに応じてランダムに1つ選ぶ
function pick(table) {
  const entries = Object.entries(table);
  let r = Math.random() * entries.reduce((sum, [, weight]) => sum + weight, 0);
  for (const [name, weight] of entries) {
    r -= weight;
    if (r < 0) {
      return name;
    }
  }
  return entries[0][0];
}

全部の数字を足した範囲でランダムな数を出し、数字を順番に引いていって、0 を下回ったところの球を選びます。こうしておくと、むずかしさの調整は数字を書きかえるだけで済みます。

しくみ5:当たり判定は「タイミング」だけ

このゲームでは、ねらう場所は合わせなくてよく、振るタイミングだけで結果が決まります。

  // err:球がとどく瞬間とのずれ(マイナス=早い、プラス=おそい)
  // 前のコマから今までの時間も足して、クリックした瞬間の時間を正確に出す
  const now = elapsed + Math.min((performance.now() - lastTime) / 1000, 1 / 30);
  const err = now - pitch.arrive;
  if (Math.abs(err) <= HIT_WINDOW) {
    pendingHit = { err };
    if (err >= 0) {
      launchHit();
    }
  }
  // タイミングがどれだけピッタリだったか(1がいちばん良い)
  const timing = 1 - Math.abs(err) / HIT_WINDOW;
  const distance = Math.round(40 + 110 * timing); // ピッタリで150m、ずれるほど飛ばない
  const foul = Math.abs(err) > HIT_WINDOW * FOUL;
  • 球がとどく瞬間の前後 0.1 秒以内に振れば、バットに当たる
  • ずれが小さいほど遠くへ飛ぶ。ピッタリなら 150m、約 0.045 秒以内のずれなら 100m をこえてホームラン
  • ずれが 0.08 秒をこえるとファウル
  • 早く振ると左へ、おそく振ると右へ飛ぶ

クリックした瞬間の時間は、「前のコマから今までの時間」も足して計算しています。画面は1秒に約60回しか描き直さないので、コマの時間だけで判定すると、最大で 0.016 秒ほどずれてしまうからです。0.045 秒の勝負なので、この差は大きいです。

まとめ

  • 球の位置は「進み具合 t」で決める。t × t で奥行きが出る
  • 変化球は sin(π × t) × t を足すだけ。最後はかならずねらった場所にとどく
  • 魔球は「t の進み方」か「描き方」を変えるだけで作れる
  • 球種の出やすさはデータにしておくと、むずかしさの調整がかんたん

このゲームを作るまでの流れは、「熱闘天敵バスターズ」ができるまでに書いています。コードの全体は GitHub で公開しています。

熱闘天敵バスターズで遊ぶ