HTML・CSS・JavaScriptだけで、ブラウザで動くスマブラ風の対戦アクション「スマッシュバトル!」を完成させるチュートリアルです。
2段ジャンプで飛び回るステージバトル、ダメージ%がたまるほど大きく吹っ飛ぶノックバック、CPUと殴り合うストック制のタイマン——全部自分の手で作って本格的な格闘アクションに仕上げよう。
⏱ 読了:約40分 🎯 対象:プログラミング初級者〜中級者
📋 もくじ
- 完成するとどうなる?
- 準備するもの
- ファイル構成を作ろう
- HTMLを書こう
- CSSでデザインしよう
- JavaScriptの設計を決めよう
- ゲームの状態を管理しよう
- キャラクターのデータを作ろう
- 入力を読み取ろう
- 移動とジャンプ
- 足場に乗る・すり抜ける
- 攻撃のしくみ
- 吹っ飛ばしの計算(スマブラの核心)
- 場外判定・KO・復活
- CPUを動かそう
- 演出でバトルを「重く」する
- キャラクターを描こう
- メインループで全部つなげる
- 動かしてみよう
- 次のステップ
- 完成コード全文
完成するとどうなる?
この記事を最後まで読むと、ブラウザ上で遊べるスマブラ風の対戦アクションが完成します。ただ殴り合うだけじゃなくて、こんな機能がついています:
- ダメージ%がたまるほど大きく吹っ飛ぶスマブラの核心ルール
- 場外に吹っ飛ばしてストック(残機)を奪い合う、3ストック制のタイマン
- 2段ジャンプ・急降下・すり抜け床を使った立体的な立ち回り
- 弱攻撃(Z)とスマッシュ(X・長押しでタメ)、方向キーで上攻撃・メテオ・下攻撃に変化
- 攻撃の「当たる瞬間」だけ判定が出る、アクティブフレームのある本格ヒットボックス
- 人間とまったく同じ操作を自分で入力してくるCPU(AI)
- ヒット時の火花、画面シェイク、一瞬時間が止まるヒットストップ、KOの衝撃波リング
- ダメージ%とストックを表示するHUD、場外キャラを指す矢印マーカー
- タイトル→バトル→リザルトの画面遷移と、勝利ファンファーレ
使うのはHTML・CSS・JavaScriptの3つだけ。2体の戦士(炎の戦士と氷のロボ)も、背景も、エフェクトも、画像ファイルは一切なしでぜんぶJavaScriptで描きます。
テトリスやインクバトルでゲームループに慣れた人が、「本格的な物理挙動」「攻撃の当たり判定とフレーム」「人間と同じ仕組みで動くAI」という一段上のテーマに挑戦できる、Canvasゲームの集大成にぴったりの題材です。
準備するもの
以下の2つだけ用意してください。
- テキストエディタ(VS CodeやAntigravityがおすすめ)
- Webブラウザ(Chrome、Edge、Safariなど何でもOK)
BGMを鳴らしたい人は、好きなmp3ファイルを1つ用意しておきましょう(なくてもゲームは動きます)。効果音のほうはファイル不要で、JavaScriptがその場で「ピコン」「ドカッ」を作ります。
ファイル構成を作ろう
パソコンのどこかに smash-battle という名前のフォルダを作って、その中にファイルを用意します。
smash-battle/
index.html ─ 画面の骨組み(キャンバス・操作説明)
style.css ─ 見た目のデザイン
script.js ─ ゲーム本体(全部の動き)
bgm.mp3 ─ BGM(お好みで)
インクバトルやアンダーテイルのときと同じ「HTML・CSS・JS の3点セット」です。
💡 大きいゲームは「頭の中で」ファイルを分ける
今回の script.js は約1800行と、これまででいちばん大きなゲームです。実は最初、中身は役割ごとに8つの部品に分けて考えました。
settings … 設定値(重力・スピードなど数字を全部ここに)
sound … 効果音
input … キーボード・マウスの入力
effects … パーティクル・画面シェイクなどの演出
cpu … CPUの頭脳(AI)
player … キャラの移動・攻撃・当たり判定
draw … 画面に絵を描く
main … 全体の進行とメインループ
大きなプログラムは、この「役割ごとのかたまり」を意識するだけでグッと読みやすくなります。ファイルを本当に分けてもいいのですが、今回は1つの script.js の中に上の順番でまとめてあります。読み込む順番が大事(設定→道具→中身→進行)というのは、料理の下ごしらえと同じです。
HTMLを書こう
index.html はとてもシンプル。キャンバスと、操作説明のパネルだけです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>スマッシュバトル!</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrapper">
<h1 class="game-title">⚔️ スマッシュバトル! ⚔️</h1>
<div class="canvas-container">
<canvas id="game" width="960" height="540"></canvas>
</div>
<div class="controls">
<div class="control-box p1">
<h2>🎮 操作方法</h2>
<table>
<tr><td>移動</td><td><kbd>←</kbd> <kbd>→</kbd></td></tr>
<tr><td>ジャンプ(2段)</td><td><kbd>↑</kbd></td></tr>
<tr><td>急降下 / すり抜け</td><td><kbd>↓</kbd></td></tr>
<tr><td>弱攻撃</td><td><kbd>Z</kbd></td></tr>
<tr><td>スマッシュ(長押しでタメ)</td><td><kbd>X</kbd></td></tr>
</table>
</div>
<div class="control-box info"> ... ルール説明 ... </div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
HTMLのポイント解説
主役は <canvas id="game">。960×540ピクセルの画用紙に、JavaScriptが背景もキャラもHUDも毎フレーム描き直します。タイトル画面・バトル画面・リザルト画面も、ぜんぶこの1枚のキャンバスの中で切り替わります(インクバトルではHUDをHTMLで作りましたが、今回は全部Canvas。作るゲームによって最適な方法が変わる、というのは毎回のテーマです)。
操作説明に使っている <kbd> タグは「キーボードのキー」を表すHTMLタグ。CSSで枠を付けると、本物のキーみたいに見えます。
CSSでデザインしよう
CSSはゲーム画面の外側(タイトル文字・キャンバスの枠・操作説明パネル)を整えるだけ。ゲームの中身の見た目はぜんぶJavaScript側です。
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
color: #eee;
}
#game {
display: block;
width: 100%; /* 画面幅に合わせて伸び縮みする */
height: auto;
background: #000;
cursor: pointer;
}
💡 キャンバスは「本来のサイズ」と「表示サイズ」が別モノ
<canvas width="960"> は中の絵の解像度、CSSの width: 100% は画面での見た目の大きさ。この2つは別物です。だからスマホでキャンバスが縮んで表示されても、中の座標は常に960×540のまま。マウスのクリック位置を計算するときは、この「本来のサイズ÷表示サイズ」の比率で変換する必要があります(後で getBoundingClientRect を使うところで出てきます)。
タイトルロゴの金色グラデーションは background-clip: text という「文字の形でグラデーションを切り抜く」テクニック。<kbd> の立体感は border-bottom を太くして「厚み」を出しています。
JavaScriptの設計を決めよう
ここからが本番です。まず、ゲームの手ざわりを決める設定値をぜんぶ定数にまとめておきます。これまでの記事と同じ鉄則で、あとから数字を1つ変えるだけで挙動がガラッと変わります。
"use strict";
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const SCREEN_W = canvas.width; // 960
const SCREEN_H = canvas.height; // 540
// ---------- 物理の設定 ----------
// ※ このゲームは1秒間に約60回計算される。「速度」は1フレームで動くpx数。
const GRAVITY = 0.55; // 毎フレーム下向きの速度がこれだけ増える
const MOVE_SPEED = 4.4; // 地上で歩く速さ
const AIR_ACCEL = 0.55; // 空中で左右に動く加速度
const JUMP_VEL = -12; // ジャンプの初速(上向きはマイナス!)
const DOUBLE_JUMP_VEL = -11; // 2段ジャンプの初速(少し弱い)
const FASTFALL = 1.1; // ↓での急降下の追加落下
const MAX_FALL = 13; // 落下速度の上限
const FRICTION = 0.78; // 地上の摩擦(毎フレーム速度をこの倍率に)
// ---------- バトルのルール ----------
const MAX_STOCKS = 3; // 残機
const CHARGE_MAX = 50; // スマッシュを最大までタメるフレーム数
const RESPAWN_INVINCIBLE = 150; // 復活後の無敵フレーム数
💡 「上」はマイナス、を体にしみこませる
JUMP_VEL = -12 がマイナスなのに注目。キャンバスの世界では下に行くほど y が増えるので、「上に飛ぶ=y を減らす=速度がマイナス」になります。算数のグラフと上下が逆、というのはCanvasゲームで最初につまずくポイント。「上はマイナス」と唱えて覚えましょう。
場外ライン(バーストライン)も定数で、画面の外側に設定します。ここを越えるとKOです。
const BLAST_LEFT = -130; // 左端
const BLAST_RIGHT = SCREEN_W + 130; // 右端
const BLAST_BOTTOM = SCREEN_H + 150; // 下端
const BLAST_TOP = -280; // 上端(吹っ飛ばされ中のみ判定)
ステージの足場も配列で定義します。main: true は下から通れない「本体ステージ」、それ以外は下から飛び乗れて↓で降りられる「すり抜け床」です。
const platforms = [
{ x: 180, y: 440, w: 600, h: 70, main: true }, // 地面(メインステージ)
{ x: 265, y: 305, w: 150, h: 14 }, // 左の浮遊足場
{ x: 545, y: 305, w: 150, h: 14 }, // 右の浮遊足場
{ x: 405, y: 195, w: 150, h: 14 }, // 上の浮遊足場
];
ゲームの状態を管理しよう
このゲームには「タイトル」「バトル」「リザルト」の3つの画面があります。今どの画面かを変数 state に1つだけ持たせます。
let state = "title"; // "title" | "battle" | "result"
let players = []; // バトル中の2人
let frameCount = 0; // 開始からのフレーム数(アニメに使う)
let winner = null; // 勝者
💡 「ステートマシン」はゲームの背骨
state の値によって、更新する内容も描く画面も変わります。この作り方をステートマシン(状態機械)と呼びます。アンダーテイル編でも出てきた、ほとんどのゲームで使う考え方です。
画面遷移は title → battle → result → title の一本道。バトルを始めるときに2人のプレイヤーを作ります。
function startBattle() {
players = [
createPlayer({ name: "プレイヤー", ...BLAZE_PALETTE, keys: PLAYER_KEYS, x: 300, y: 300, facing: 1 }),
createPlayer({ name: "CPU", ...FROST_PALETTE, keys: null, x: 630, y: 300, facing: -1, isCPU: true }),
];
particles = []; koRings = []; shake = 0; winner = null; // 前の試合の演出をリセット
state = "battle";
SE.select();
}
...BLAZE_PALETTE の ...(スプレッド構文)は「オブジェクトの中身を展開して混ぜる」書き方。色の設定をまるごと流し込めて便利です。リセット処理を1か所に集めるのは、これまでの記事と同じ鉄則です。
キャラクターのデータを作ろう
プレイヤーもCPUも、同じ関数 createPlayer から作るまったく同じ構造のデータです。ちがいは「操作するのが人間か(isCPU)」だけ。
function createPlayer(opts) {
return {
name: opts.name, color: opts.color, /* ...見た目... */ look: opts.look,
isCPU: opts.isCPU || false, keys: opts.keys,
x: opts.x, y: opts.y, w: 34, h: 46, // 位置と大きさ(x,yは左上の角)
vx: 0, vy: 0, // 速度(1フレームで動くpx)
facing: opts.facing, // 向き(1=右, -1=左)
onGround: false,
jumps: 2, // 残りジャンプ回数(2段ジャンプ)
damage: 0, // ダメージ%(高いほど吹っ飛ぶ)
stocks: MAX_STOCKS, // 残機
hitstun: 0, // 吹っ飛ばされ中で操作できない残りフレーム
invincible: 0, // 復活直後の無敵
attack: null, cooldown: 0, charging: false, chargeTime: 0,
dropTimer: 0, respawnTimer: 0, walkPhase: 0,
// CPU用の記憶(クールダウンなど)。人間では使わない
ai: { atkCd: 30, jumpCd: 0, holdFrames: 0, wanderDir: 0, wanderT: 0 },
};
}
キャラが持つ数字が全部そろっているので、この関数を読むだけで「このゲームでキャラができること」が一覧できます。データ構造を見ればゲームが分かる、というのは良い設計のサインです。
入力を読み取ろう
格闘アクションでは、入力を2種類に分けて覚えるのが重要です。
const keysDown = {}; // 押しっぱなし(移動に使う)
const justPressed = new Set(); // このフレームで「押した瞬間」(ジャンプ・攻撃に使う)
💡 「押しっぱなし」と「押した瞬間」を分ける理由
移動は「←を押している間ずっと動く」ので keysDown を見ます。でもジャンプは「↑を押した瞬間に1回だけ」でないと、押しっぱなしで空へ飛び続けてしまう。だから「今このフレームで押されたか」を別に記録します。
window.addEventListener("keydown", (e) => {
if (GAME_KEYS.has(e.code)) e.preventDefault(); // 画面スクロールを防ぐ
ensureAudio(); // 最初の入力で音を有効化
if (!e.repeat) justPressed.add(e.code); // 長押しのリピートは含めない
keysDown[e.code] = true;
});
window.addEventListener("keyup", (e) => { keysDown[e.code] = false; });
ポイントは if (!e.repeat)。キーを押しっぱなしにするとOSが「リピート入力」を連発しますが、それを「押した瞬間」に含めないことで、タメっぱなしでもジャンプが暴発しません。そして justPressed は毎フレームの最後に空っぽにする(メインループで justPressed.clear())ので、常に「今このフレーム」だけを表します。
人間の入力は、キー設定に合わせてこの2つから読み取ります。CPUも同じ形の「入力」を作るので、あとの処理は人間もCPUもまったく共通になります。
function humanInput(player) {
const k = player.keys;
return {
left: !!keysDown[k.left], right: !!keysDown[k.right],
up: !!keysDown[k.up], down: !!keysDown[k.down],
jumpPressed: justPressed.has(k.up), // ↑を「今押した」
attackPressed: justPressed.has(k.attack), // 弱攻撃を「今押した」
smashHeld: !!keysDown[k.smash], // スマッシュを押し続けている
smashPressed: justPressed.has(k.smash), // スマッシュを「今押した」
downPressed: justPressed.has(k.down),
};
}
移動とジャンプ
物理の基本は、これまでと同じ「速度に重力を足す→位置に速度を足す」です。
function applyPhysics(player, input) {
player.vy += GRAVITY; // 重力:下向きの速度が毎フレーム増える
// ↓キーで急降下
if (input.down && player.vy > 0 && player.hitstun <= 0) player.vy += FASTFALL;
// 落下速度に上限(急降下中は上限が上がる)
const fallLimit = MAX_FALL + (input.down ? 6 : 0);
if (player.vy > fallLimit) player.vy = fallLimit;
// 地上で左右キーを押していないときは摩擦で減速
if (player.onGround && player.hitstun <= 0) {
if (!input.left && !input.right) player.vx *= FRICTION;
}
}
player.vx *= FRICTION(毎フレーム0.78倍)が摩擦の正体。「かけ算で少しずつ減らす」と、キュッと止まらずスーッと滑って止まる自然な減速になります。
ジャンプは「2段ジャンプ」対応。残りジャンプ回数 jumps を使います。
function doJump(player, input) {
if (!player.charging && input.jumpPressed && player.jumps > 0) {
// 1回目は強く、2段目は少し弱く
player.vy = player.onGround || player.jumps === 2 ? JUMP_VEL : DOUBLE_JUMP_VEL;
player.jumps--;
player.onGround = false;
SE.jump();
spawnParticles(player.x + player.w / 2, player.y + player.h, "#aaddff", 6, 2, 15, 3);
}
}
💡 空中と地上で操作感を変える
移動は、地上ではキビキビ、空中ではふわっと効かせます。
if (player.onGround) {
player.vx = move * MOVE_SPEED; // 地上:すぐ最高速
} else {
player.vx += move * AIR_ACCEL; // 空中:少しずつ加速
player.vx = Math.max(-MOVE_SPEED, Math.min(MOVE_SPEED, player.vx));
}
地上は「速度をパッと決める」、空中は「じわじわ加速して上限で頭打ち」。この差が、地に足のついた歩きと、慣性の効いた空中制御という気持ちよさのちがいを生みます。着地でジャンプ回数が2に戻る(landOnPlatform の中で player.jumps = 2)ので、また2段ジャンプできます。
足場に乗る・すり抜ける
足場判定のいちばん大事なルールは、「上から落ちてきたときだけ乗れる」こと。これを実現するために、動く前の足の位置 prevBottom を覚えておきます。
function landOnPlatform(player, prevBottom) {
player.onGround = false;
if (player.vy >= 0 && player.dropTimer === 0) { // 落下中で、すり抜け中でないとき
for (const plat of platforms) {
if (
player.x + player.w > plat.x + 4 && // 横方向に重なっていて
player.x < plat.x + plat.w - 4 &&
prevBottom <= plat.y + 1 && // さっきまで足が床より上にあり
player.y + player.h >= plat.y // 今は床に届いた
) {
player.y = plat.y - player.h; // 足を床の上にぴったり合わせる
player.vy = 0;
player.onGround = true;
player.jumps = 2; // ジャンプ回数が回復
break;
}
}
}
}
💡 「さっきは上・今は下」で貫通をすり抜けと区別する
prevBottom <= plat.y(さっきは足が床より上)かつ player.y + player.h >= plat.y(今は床に届いた)という2つの条件がキモ。これで「上から降りてきて着地した」ときだけ乗り、下からジャンプで突き抜けるときは乗りません。速く落ちて床をすり抜けてしまう「トンネリング」も、この「前の位置と比べる」やり方で防げます。
↓キーですり抜け床から降りるのは、dropTimer を使って「一定フレームのあいだ床に乗らない」ようにするだけ。
function doDropThrough(player, input) {
if (!player.charging && input.downPressed && player.onGround) {
const plat = currentPlatform(player);
if (plat && !plat.main) { // メインステージはすり抜けられない
player.dropTimer = 12; // 12フレーム床に乗らない
player.y += 4;
player.onGround = false;
}
}
}
攻撃のしくみ
攻撃には弱攻撃(Z)とスマッシュ(X)の2種類、それぞれ押している方向キーで技が変化します。
function attackDir(input, player) {
if (input.up) return "up"; // 上攻撃
if (input.down && !player.onGround) return "meteor"; // 空中で↓ = メテオ
if (input.down && player.onGround) return "low"; // 地上で↓ = 下攻撃
return "side"; // 何も = 横攻撃
}
スマッシュは「地上でXを押すとタメ開始、離すと発動」。タメるほど威力が上がります。
function doCharge(player, input) {
player.chargeTime = Math.min(CHARGE_MAX, player.chargeTime + 1);
// ボタンを離した、または最大までタメたら発動
if (!input.smashHeld || player.chargeTime >= CHARGE_MAX + 10) {
const ratio = player.chargeTime / CHARGE_MAX; // タメの割合(威力に反映)
player.charging = false;
startAttack(player, "smash", attackDir(input, player), ratio);
}
}
💡 「アクティブフレーム」——攻撃が当たる“瞬間”だけ判定を出す
ここが格闘ゲームらしさの心臓部です。攻撃モーションの最初と最後には当たり判定がなく、真ん中の数フレームだけ判定が出ます。
function attackHitbox(player) {
const atk = player.attack;
if (!atk) return null;
// 判定が有効なフレームの範囲 [開始, 終了]
const activeFrames = atk.type === "smash" ? [6, 14] : [3, 9];
if (atk.timer < activeFrames[0] || atk.timer > activeFrames[1]) return null;
// ...方向ごとに判定の四角を返す...
}
弱攻撃は3〜9フレーム目、スマッシュは6〜14フレーム目だけ「当たる」。これがあるおかげで、「攻撃を振り始めた瞬間はまだ当たらない」「振り終わりにはスキがある」という駆け引きが生まれます。判定が出っぱなしだと、置いておくだけで勝ててしまってつまらない。「いつ当たるか」までデザインするのが格闘ゲームなんです。
当たり判定そのものは、四角と四角が重なるかを見るだけ。
function rectsOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
吹っ飛ばしの計算(スマブラの核心)
スマブラが他の格闘ゲームと決定的にちがうのが、「体力がゼロで負け」ではなく「ダメージがたまるほど吹っ飛びやすくなり、場外に出たら負け」というルール。その心臓部がこの数行です。
function applyHit(attacker, victim) {
const atk = attacker.attack;
const isSmash = atk.type === "smash";
// --- ダメージ計算(タメるほど倍率アップ、最大約1.9倍) ---
const chargeMult = 1 + atk.charge * 0.9;
const dmg = (isSmash ? 13 : 6) * chargeMult;
victim.damage = Math.min(999, victim.damage + Math.round(dmg));
// --- 吹っ飛ばし力(ノックバック) ---
// スマブラの核心: 「基礎の力 + 相手のダメージ%に比例した力」
const base = isSmash ? 8 : 4.5;
const knockback = (base + victim.damage * 0.115) * (isSmash ? chargeMult : 1);
// ...
}
💡 たった1本の式が、ゲーム全体のルールを決める
knockback = base + victim.damage * 0.115 ——これだけ。相手のダメージ%が高いほど、吹っ飛ぶ力が大きくなる。0%の相手はちょっとしか動かないけど、150%の相手は画面外まで吹っ飛ぶ。この1本の式が「序盤はダメージを稼ぎ、たまったら強い技でしめる」というスマブラ独特の試合の流れを生み出しています。ゲームのおもしろさが、数式1本に凝縮されているgood例です。
吹っ飛ぶ向きは技によって変えます。上攻撃は真上、メテオは下(空中の相手を叩き落とす)、下攻撃は横に低く、など。
switch (atk.dir) {
case "up": kvx = attacker.facing * knockback * 0.25; kvy = -knockback * 1.15; break;
case "meteor": kvx = attacker.facing * knockback * 0.3;
kvy = victim.onGround ? -knockback * 0.9 : knockback * 0.95; break; // 空中なら下へ
case "low": kvx = attacker.facing * knockback * 1.1; kvy = -knockback * 0.35; break;
default: kvx = attacker.facing * knockback; kvy = -knockback * 0.55; // 横攻撃
}
victim.vx = kvx; victim.vy = kvy;
// 吹っ飛ばされ中は操作できない(強いほど長い。最大48フレーム)
victim.hitstun = Math.min(48, 8 + knockback * 1.5);
attacker.facing(攻撃した人の向き)を掛けているので、右向きなら右へ、左向きなら左へ飛びます。そして強く吹っ飛ばすほど hitstun(操作不能時間)が長くなる——だから大技を当てると相手は復帰も反撃もできず、そのまま場外へ、という爽快感につながります。
場外判定・KO・復活
キャラの中心が場外ラインを越えたらKOです。
function checkBlastZone(player) {
const cx = player.x + player.w / 2;
const cy = player.y + player.h / 2;
if (
cx < BLAST_LEFT || cx > BLAST_RIGHT || cy > BLAST_BOTTOM ||
// 上だけは特別: 吹っ飛ばされ中に越えたときだけKO
(cy < BLAST_TOP && player.hitstun > 0)
) {
koPlayer(player);
}
}
💡 上方向だけルールを変える親切心
左右と下は「越えたら即KO」ですが、上だけは「吹っ飛ばされ中(hitstun > 0)のときだけKO」にしています。これがないと、自分でジャンプして画面上に飛び出しただけでKOになってしまう。「自分の操作ミスと、相手に飛ばされたのを区別する」——こういう細かい親切が、理不尽さをなくしてゲームを気持ちよくします。
KOされたら残機を1つ減らし、0になったら試合終了。まだ残っていれば、一度画面外に隠してから復活タイマーをセットします。
function koPlayer(player) {
player.stocks--;
spawnKO(player.x + player.w / 2, player.y + player.h / 2, player.color);
SE.ko();
shake = 22;
if (player.stocks <= 0) {
endBattle();
} else {
player.respawnTimer = 80; // 80フレーム後に復活
player.x = -9999; player.y = -9999; // いったん画面のはるか外へ
}
}
function respawn(player) {
player.x = SCREEN_W / 2 - player.w / 2;
player.y = 60; // ステージ中央の上空に
player.damage = 0; // ダメージ%はリセット
player.invincible = RESPAWN_INVINCIBLE; // しばらく無敵
}
復活直後は RESPAWN_INVINCIBLE(150フレーム=約2.5秒)の無敵。無敵時間はゲームの優しさ、というのはインクバトル編でも出てきた鉄則です。復活した瞬間に殴られて何もできない、を防ぎます。
CPUを動かそう
CPUの作り方が、このゲームでいちばん美しい設計かもしれません。CPUは人間とまったく同じ「入力」を毎フレーム作るだけ。あとの移動・攻撃・物理は人間と同じ関数を通ります。つまり cpu.js は「CPUの指」を作っているだけなんです。
const input = player.isCPU ? cpuInput(player, enemy) : humanInput(player);
この1行が肝。humanInput(キーボードから作る)と cpuInput(AIが考えて作る)は、同じ形の「入力」を返します。だから受け取る側は、人間かCPUかを気にせず処理できます。
CPUの「作戦」は優先順位つきの4段構え。
1. 場外に落ちそう? → 何よりまずステージに戻る(最優先!)
2. 相手がKO中? → うろうろして待つ
3. 相手が遠い? → 近づく
4. 相手が近い? → たまに技を選んで攻撃
function cpuInput(cpu, enemy) {
const input = { left:false, right:false, up:false, /* ... */ };
const ai = cpu.ai;
if (ai.atkCd > 0) ai.atkCd--; // 攻撃のクールダウンを減らす
// 作戦1: 場外にいる! ステージへ戻る
if (cpuCenterX < stageLeft - 10 || cpuCenterX > stageRight + 10) {
if (cpuCenterX < stageLeft) input.right = true; else input.left = true;
if (cpu.vy > 1 && cpu.jumps > 0 && ai.jumpCd === 0) { // 落ち始めたらジャンプで復帰
input.jumpPressed = true; ai.jumpCd = 18;
}
return input;
}
// 作戦4: 近ければ攻撃(35%でスマッシュ、それ以外は弱攻撃)
if (dist < 75 && Math.abs(dy) < 65 && ai.atkCd === 0) {
if (Math.random() < 0.35 && cpu.onGround) { input.smashPressed = true; /* ... */ }
else { input.attackPressed = true; /* 相手が上なら上攻撃、下ならメテオ */ }
ai.atkCd = 35 + Math.floor(Math.random() * 40); // 連打しすぎ防止
}
return input;
}
💡 クールダウンと乱数で「人間らしさ」を出す
ai.atkCd(攻撃のクールダウン)と Math.random() があちこちに入っているのに注目。もしCPUが毎フレーム最善手を打ったら、正確すぎて絶対に勝てません。わざと待ち時間を入れ、たまにサイコロを振ることで、ちょっと隙のある「人間っぽい」相手になります。強いAIを作るより、遊んで楽しいAIに調整するのがゲームAIの腕の見せどころです。
演出でバトルを「重く」する
同じ攻撃でも、演出があるかないかで「手ごたえ」がまるで変わります。このゲームには4つの演出が入っています。
① パーティクル(火花) — ヒット時に小さな四角を放射状にまき散らし、重力で落とします。
function spawnParticles(x, y, color, count, speed, life = 30, size = 4) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = speed * (0.4 + Math.random() * 0.8);
particles.push({ x, y, vx: Math.cos(angle)*spd, vy: Math.sin(angle)*spd,
life: life*(0.6+Math.random()*0.6), maxLife: life, color, size, grav: 0.12 });
}
}
② 画面シェイク — 描画位置を毎フレームランダムにずらし、だんだん弱めます。
if (shake > 0) {
ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
shake *= 0.88; // 揺れをだんだん弱める
}
③ ヒットストップ — 攻撃が当たった瞬間、ゲーム全体を数フレーム止めます。
function updateBattle() {
if (hitpause > 0) { hitpause--; return; } // ここで return すると世界が止まる
// ...通常の更新...
}
💡 「一瞬止める」だけで攻撃が重くなる
applyHit の中で hitpause = isSmash ? 8 : 4 とセットすると、当たった瞬間に世界が一瞬フリーズします。たった数フレームですが、これがあるだけで攻撃に「ズシッ」という重みが出ます。アクションゲームの気持ちよさは、このヒットストップと画面シェイクの合わせ技で作られていることがとても多い。プロのゲームでも必ず使われる、コスパ最強の演出です。
④ KOリング — KOの瞬間に広がる衝撃波の輪。だんだん大きく&透明にして消します。
キャラクターを描こう
キャラの描画は「足元の中心を原点にする」のがコツ。ctx.translate で原点を移してから描くので、体のパーツは全部「足元からの相対位置」で書けます。
function drawFighter(p) {
// ...
ctx.save();
ctx.translate(cx, bottom); // 足元の中心を原点(0,0)にする
// 左右の向きに合わせて反転 + 空中では体を縦に伸ばす
const stretch = p.onGround ? 1 : Math.min(1.1, 1 + Math.abs(p.vy) * 0.006);
ctx.scale(p.facing * (2 - stretch), stretch);
drawScarf(p); // なびくマフラー(炎の戦士のみ)
drawLegs(p); // 脚(歩行アニメ)
drawBody(p); // 胴体・エンブレム・ベルト・肩
drawHead(p); // 頭
drawFrontArm(p); // 前の腕(攻撃モーションもここ)
ctx.restore();
}
💡 sin波が「生きてる感」を作る
キャラを生き生き見せる工夫は、ほとんど Math.sin でできています。歩くときの脚の前後(Math.sin(walkPhase))、マフラーのひらひら、炎の戦士の髪のゆらぎ、タイトルロゴの上下バウンド——ぜんぶ「時間を sin に入れて -1〜+1の波を作る」だけ。アンダーテイル編のボスの呼吸と同じで、ゲームの生命感の正体はだいたい sin波です。
ctx.scale(p.facing, ...) で左右反転しているので、キャラは1方向ぶんだけ描けばOK。p.look の値("blaze" か "frost")で頭のデザインを炎の戦士とメカロボに描き分けています。攻撃中は前の腕を Math.sin(prog * π) で「伸びて戻る」パンチにし、スマッシュには光る斬撃トレイルを重ねます。
メインループで全部つなげる
いよいよ心臓部。requestAnimationFrame で毎秒約60回呼ばれ、state に応じて「更新→描画」を繰り返します。
function loop() {
frameCount++;
if (state === "title") {
updateTitle();
if (state === "title") drawTitle(); else drawBattle();
} else if (state === "battle") {
updateBattle();
if (state === "battle") drawBattle(); else drawResult();
} else if (state === "result") {
updateResult();
if (state === "result") drawResult(); else drawTitle();
}
// 「押した瞬間」の記録はこのフレーム限りなのでリセット
justPressed.clear();
clickPos = null;
requestAnimationFrame(loop); // 1秒間に約60回、loop を呼び続ける
}
loop();
💡 「更新したら、切り替わった先を描く」小さな工夫
updateTitle() の直後に if (state === "title") drawTitle(); else drawBattle(); と、もう一度 state を確認しているのがポイント。更新の途中で画面が切り替わった場合に、「1フレームだけ古い画面がチラッと見える」のを防いでいます。細かいですが、こういう気配りが「なんか完成度が高い」の正体です。
バトルの更新(updateBattle)は「2人を動かす→攻撃の当たり判定→演出の更新」の順。当たり判定では players[1 - i](相手)という書き方で、iが0なら1、1なら0、と相手を指しています。1つの攻撃は1回しか当たらないよう attack.hit フラグで管理するのも、二重ヒットを防ぐ定番テクです。
動かしてみよう
ファイルを保存したら、index.html をダブルクリックしてブラウザで開いてみましょう。Enterキーかクリックでバトルスタートです!
←→:移動、↑:2段ジャンプ、↓:急降下/すり抜け、Z:弱攻撃、X:スマッシュ(長押しでタメ)、方向キー+攻撃で技が変化
CPUにできるだけダメージを与えてから、強いスマッシュで場外へ吹っ飛ばそう。3ストック先取ならぬ、相手のストックを先に0にしたら勝ち!
もし動かない・エラーが出る場合は以下をチェック:
- ファイルが同じフォルダに入っているか?
- ファイル名は
index.html、style.css、script.jsか? - ブラウザの開発者ツール(F12)の Console タブにエラーが出ていないか?
- 効果音が鳴らない場合は、一度キーを押したかクリックしたか?(ブラウザの仕様で、ユーザー操作なしには音が鳴らせません)
次のステップ
動いたら、自分なりに改造して遊んでみよう。「数字を変える → 見た目を変える → 機能を増やす」の順に少しずつ攻めるのがおすすめです。
🟢 簡単(定数をいじるだけ)
MAX_STOCKSを 5 にして長期戦に、1 にして一本勝負にGRAVITYやJUMP_VELを変えて、月面のようなふわふわジャンプにknockbackの* 0.115を大きくして、超吹っ飛ぶ爽快モードにCHARGE_MAXを小さくして、すぐMAXになるスマッシュにplatformsの座標を変えて、足場の配置を変える
🟡 中くらい(見た目と技を変える)
BLAZE_PALETTE/FROST_PALETTEの色を変えて、オリジナルカラーの戦士にdrawHeadを描きかえて、自分だけのキャラデザインにするactiveFramesをいじって、判定の出る速い技・遅い技を作る- 攻撃の
duration(モーションの長さ)を変えて、技の出しやすさを調整する - CPUの
Math.random() < 0.35(スマッシュ率)を変えて、AIの性格を変える
🔴 むずかしい(機能を追加する)
- 2人対戦にする(CPUのキー設定を人間用に変えて
isCPU: false) - 飛び道具の技を追加する(弾のオブジェクトを飛ばす)
- ガード/回避を追加して、駆け引きを深くする
- 落ちているアイテム(回復・強化)を出す
- CPUに「相手のスマッシュを見てからガードする」など、より賢い判断をさせる
学べることが盛りだくさん
このゲーム1本で、こんなことが学べます:
- 重力・摩擦・空中制御・2段ジャンプなど、本格的な2D物理の作り方
- 足場の当たり判定(前の位置と比べる/すり抜け床)
- 攻撃のアクティブフレームと四角どうしの当たり判定
- スマブラの核心であるダメージ比例ノックバックの設計
- 人間とCPUを同じ入力で動かすきれいな設計
- ヒットストップ・画面シェイク・パーティクルでアクションを気持ちよくする
- ステートマシンによる画面遷移、
Math.sinによるアニメーション
わからないことがあったら「JavaScript Canvas ○○」で検索してみよう。今回覚えた物理・当たり判定・AI・演出は、アクションでもシューティングでも使うゲーム作りの総合力。
このスマッシュバトルを改造しているうちに、あなたはもう立派なゲームクリエイターです!
完成コード全文
index.html と style.css は完成版がこの記事のフォルダに入っています。script.js は約1800行あるので、ここではファイルのダウンロードリンクを置きます。
全行に日本語コメントが付いているので、ダウンロードして読みながら写経するのもおすすめです。