作り方
変化球と魔球の作り方|消える・止まる・分身する球を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 で公開しています。


