2026年3月18日水曜日

【解説記事】ブログで横スクロールアクションを作りたい人は参考にどうぞ!

今までいくつかのブログ記事で遊べるゲームを作ってきましたが、今回試しに作ってみたのは横スクロールアクションゲーム!

ファミコン時代によくあったゲームです。

今回、Geminiを交えて解説記事を作ってみましたので、自分の記事でHTMLの横スクロールゲームを設置してみたい!という方はどうぞ!

ちなみに見た目はこんな感じ。



↓試しにゲームを遊びたい方はこれをどうぞ
Emoji Action - Perfect Face
SCORE: 0

【A/D】移動 【J】ジャンプ



では次にこのゲームのコードを参考に、Geminiに解説をしてもらっていきます。
Game Source Code
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Emoji Action - Perfect Edition</title>
    <style>
        #gameWrapper { text-align: center; background: #222; padding: 20px; color: white; font-family: sans-serif; }
        #gameCanvas { border: 6px solid #444; background: #87CEEB; cursor: pointer; display: block; margin: 0 auto; }
    </style>
</head>
<body>
<div id="gameWrapper">
    <div id="hud" style="font-size: 24px; font-weight: bold; margin-bottom: 10px;">SCORE: <span id="score">0</span></div>
    <canvas id="gameCanvas" width="600" height="400"></canvas>
</div>

<script>
(function() {
    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    let audioCtx = null;

    function playSound(type) {
        if (!audioCtx) return;
        const osc = audioCtx.createOscillator();
        const gain = audioCtx.createGain();
        osc.connect(gain); gain.connect(audioCtx.destination);
        if (type === 'coin') {
            osc.type = 'square'; osc.frequency.setValueAtTime(987, audioCtx.currentTime);
            osc.frequency.setValueAtTime(1318, audioCtx.currentTime + 0.05);
            gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
            gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
            osc.start(); osc.stop(audioCtx.currentTime + 0.3);
        } else if (type === 'jump') {
            osc.type = 'square'; osc.frequency.setValueAtTime(400, audioCtx.currentTime);
            osc.frequency.exponentialRampToValueAtTime(800, audioCtx.currentTime + 0.1);
            gain.gain.setValueAtTime(0.05, audioCtx.currentTime);
            gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
            osc.start(); osc.stop(audioCtx.currentTime + 0.1);
        } else if (type === 'stomp') {
            osc.type = 'triangle'; osc.frequency.setValueAtTime(200, audioCtx.currentTime);
            osc.frequency.exponentialRampToValueAtTime(600, audioCtx.currentTime + 0.1);
            gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
            gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
            osc.start(); osc.stop(audioCtx.currentTime + 0.1);
        } else if (type === 'miss') {
            osc.type = 'sawtooth'; osc.frequency.setValueAtTime(150, audioCtx.currentTime);
            gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
            gain.gain.linearRampToValueAtTime(0.01, audioCtx.currentTime + 0.5);
            osc.start(); osc.stop(audioCtx.currentTime + 0.5);
        }
    }

    let state = 'START', worldX = 0, score = 0, gravity = 0.8, groundY = 320;
    let p = { x: 100, y: 300, vx: 0, vy: 0, dir: 1, walk: 0, ground: true, canJump: true };
    let enemies = [], boxes = [], goalX = 4000;
    let keys = {};

    function init() {
        enemies = [];
        for(let i=0; i<15; i++) enemies.push({ x: 800 + i*400 + Math.random()*200, y: groundY, vx: -2.5, alive: true });
        boxes = [];
        for(let i=0; i<12; i++) boxes.push({ x: 500 + i*550, y: 160 + (i%2)*40, hit: false, t: 60 });
        worldX = 0; score = 0; p.x = 100; p.y = 300; p.vy = 0; p.vx = 0;
        document.getElementById('score').innerText = score;
    }

    window.addEventListener('keydown', e => { 
        keys[e.key.toLowerCase()] = true; 
        if(state==='START' || state==='GOAL') { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); state='PLAY'; init(); }
    });
    window.addEventListener('keyup', e => {
        keys[e.key.toLowerCase()] = false;
        if(e.key.toLowerCase() === 'j') p.canJump = true;
    });
    canvas.addEventListener('mousedown', () => { if(state==='START' || state==='GOAL') { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); state='PLAY'; init(); } });

    function update() {
        if (state !== 'PLAY') return;
        p.vx = 0;
        if (keys['a']) { p.vx = -5; p.dir = -1; }
        if (keys['d']) { p.vx =  5; p.dir =  1; }
        p.x += p.vx;
        if (p.x < 20) p.x = 20;
        if (p.x > 300) { worldX += p.vx; p.x = 300; }
        if (keys['j'] && p.ground && p.canJump) { 
            p.vy = -16; p.ground = false; p.canJump = false; playSound('jump'); 
        }
        p.vy += gravity; p.y += p.vy;
        if (p.y >= groundY) { p.y = groundY; p.vy = 0; p.ground = true; }
        if (Math.abs(p.vx) > 0 && p.ground) p.walk++; else p.walk = 0;

        enemies.forEach(en => {
            if (!en.alive) return; en.x += en.vx;
            if (Math.abs((p.x + worldX) - en.x) < 20 && Math.abs(p.y - en.y) < 25) {
                if (p.vy > 0 && p.y < en.y) { en.alive = false; p.vy = -12; score += 100; playSound('stomp'); }
                else { playSound('miss'); state = 'START'; }
            }
        });
        boxes.forEach(b => {
            if (b.hit) { b.t--; return; }
            if (Math.abs(p.x+worldX - b.x) < 25 && p.y-b.y > 0 && p.y-b.y < 45 && p.vy < 0) {
                b.hit = true; score += 100; p.vy = 2; playSound('coin');
            }
        });
        document.getElementById('score').innerText = score;
        if (p.x + worldX >= goalX) state = 'GOAL';
    }

    function draw() {
        ctx.clearRect(0, 0, 600, 400);
        ctx.fillStyle = "#4a7a4a";
        for(let i=0; i<15; i++){
            let mx = i*500 - worldX*0.2;
            ctx.beginPath(); ctx.moveTo(mx, 340); ctx.lineTo(mx+250, 100); ctx.lineTo(mx+500, 340); ctx.fill();
        }
        ctx.fillStyle = "#8b4513"; ctx.fillRect(-worldX, 340, goalX+1000, 60);
        ctx.font = "30px serif";
        boxes.forEach(b => { if(!b.hit || b.t>0) ctx.fillText(b.hit?"🪙":"🎁", b.x-worldX-15, b.y); });
        enemies.forEach(en => { if(en.alive) ctx.fillText("🐢", en.x-worldX-15, en.y + 15); });
        ctx.fillText("🚩", goalX-worldX, 340);

        const sw = Math.sin(p.walk*0.2), cx = p.x, cy = p.y;
        ctx.strokeStyle = "#333"; ctx.lineWidth = 4;
        ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx+sw*15, cy+20); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx-sw*15, cy+20); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(cx, cy-20); ctx.lineTo(cx-sw*12, cy-5); ctx.stroke();
        ctx.fillStyle = "#333"; ctx.fillRect(cx-8, cy-25, 16, 25);
        ctx.fillStyle = "#fcc"; ctx.beginPath(); ctx.arc(cx, cy-37, 12, 0, Math.PI * 2); ctx.fill();
        ctx.fillStyle = "#00f"; ctx.beginPath(); ctx.arc(cx, cy-39, 14, 3.1, 7); ctx.fill();
        ctx.fillRect(cx+(2 * p.dir), cy-42, 16 * p.dir, 4);
        ctx.fillStyle = "#000"; ctx.fillRect(cx + (6 * p.dir), cy - 40, 3, 3);

        if (state === 'START') { 
            ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(0,0,600,400); 
            ctx.fillStyle="white"; ctx.font="bold 30px sans-serif"; ctx.fillText("CLICK TO START", 180, 200); 
        }
        if (state === 'GOAL') { 
            ctx.fillStyle="rgba(255,255,0,0.4)"; ctx.fillRect(0,0,600,400);
            ctx.fillStyle="white"; ctx.font="bold 50px sans-serif"; ctx.fillText("GOAL!!", 210, 200); 
        }
        update(); requestAnimationFrame(draw);
    }
    draw();
})();
</script>
</body>
</html>
    

途中途中「魔法」という文字が使われていますが、私が使ってるGeminiはその文字が好きらしく使ってきます。

たぶんプログラム…って言いたいのかも?

…それと申し訳ないのですが、1つ1つ解説を入れてみたところ…凄く長くなってしまったため、序盤のScriptの解説のところまでとなっています。

(ゲームの状態(変数)の管理まで)

※コードの解説はGeminiに頼んでいますので、もしかしたら間違えてる可能性もあります。


1. 舞台の基本設定(HTML部分)

<!DOCTYPE html>

「これは最新のHTML(ウェブページ)の書き方ですよ」という宣言です。

これを書くことで、ブラウザが正しくゲームを表示してくれます。


<html lang="ja">

このページのメイン言語が「日本語」であることを示しています。


<meta charset="UTF-8">

文字化けを防ぐための設定です。「日本語の文字も綺麗に表示してね」というお約束事ですね。


<title>Emoji Action - Perfect Edition</title>

ブラウザのタブ(一番上の名前)に表示されるタイトルです。

<title> と </title>に囲まれた間に好きな文字を入れましょう。



2.見た目のデザイン設定(CSS部分)

<style> から始まる部分は、ゲーム画面の「色」や「形」を指定しています。


#gameWrapper { ... }

ゲーム全体を包む「外枠」の設定です。


・text-align: center; 中身(タイトルやゲーム画面)を真ん中に寄せます。


・background: #222; 外側の背景をかっこいい「濃いグレー」にします。


・padding: 20px; 枠の内側に少し余白を作って、窮屈にならないようにしています。



#gameCanvas { ... }

実際にキャラクターが走り回る「ゲーム画面(キャンバス)」の設定。

・border: 6px solid #444; 画面の周りに、太いグレーの縁取り。


・background: #87CEEB; ステージの空の色(水色)。


・cursor: pointer; マウスを重ねた時に、指の形(クリックできるよ!というサイン)に変えます。


・display: block; 「この要素(ゲーム画面)を、一行まるごと占領する『大きな箱』として扱ってください!」という命令。

※ちなみにこれ、なくてもいいんじゃないかな?と思ったのですが、これが無いと今後付け足していく時にレイアウトが崩れたり、サイズがおかしくなるとの事でした。


・margin: 0 auto; ゲーム画面をブラウザのちょうど真ん中に配置する魔法の言葉。



3. ゲーム画面のパーツ配置(HTML本体)

<body>

ここから下が、実際にブラウザの画面に表示される中身になります。


<div id="gameWrapper">

先ほどCSSで設定した「外枠の箱」です。この中にスコアやゲーム画面をひとまとめに入れることで、全体を真ん中に寄せたり、背景色をつけたりしています。


<div id="hud"> ... </div>

「HUD(ハッド)」とは、ゲーム用語で「画面上に常に表示される情報(ヘッドアップディスプレイ)」のことです。

・SCORE: という文字を出し、その横に <span id="score">0</span> を置いています。

・ここがポイント! この <span> の中身(数字)は、ゲームが動いている間にJavaScriptが「100点入った!書き換えて!」と命令して、どんどん数字を増やしていくための「窓口」になっています。


<canvas id="gameCanvas" width="600" height="400"></canvas>

これこそが、丹精込めて作ったゲームが映し出される 「魔法のスクリーン」 です!

・width="600" height="400":スクリーンの大きさを、横600ピクセル、縦400ピクセルに指定しています。

・この真っ白(または水色)な板の上に、JavaScriptが1秒間に60回、猛スピードで絵を描き続けることで、ゲームが動いて見えます。


💡 Geminiのアドバイス

「HTMLは 『何があるか』 を書く場所で、CSSは 『どう見えるか』 を書く場所。この3番ではゲームに必要な『スコア盤』と『スクリーン』を、部屋の中に運び込んだ状態だよ!」



4. JavaScriptの起動とスクリーンの準備

プログラムが動き出す一番最初の設定です。

(function() {

    const canvas = document.getElementById('gameCanvas');

    const ctx = canvas.getContext('2d');

    let audioCtx = null;


(function() { ... })();:

これは「即時関数」といって、この中のプログラムが他の場所と混ざって悪さをしないように、専用の個室を作って閉じ込める魔法の書き方です。


const canvas =

これは、さきほどHTMLで作った 「gameCanvas(スクリーンの枠組み)」 を、JavaScriptから動かせるように「キャンバス」という変数に代入して捕まえている。

いわば「作業台を確保した」 という状態。


document.getElementById('gameCanvas'):

さきほどHTMLで作った「gameCanvas」という名前のスクリーンを、JavaScriptの世界に連れてくる命令です。


const ctx =

これが最も重要です! ctx(コンテキスト)は、キャンバスという画板に絵を描くための 「超高性能な筆」 だと思ってください。

・「肌色で丸を塗って!」

・「ここに四角い地面を描いて!」

といった命令は、すべてこの ctx という筆を通して行われます。

ちなみに 2d というのは、「3D(立体)じゃなくて、2D(平面)の絵を描くモードですよ」という指定です。


getContext('2d'):

これがいわゆる「筆」に当たります!「2D(平面)の絵を描くモードにしますよ」と宣言しています。


let audioCtx = null;:

音響監督(AudioContext)の席を、最初は空(null)にして用意だけしておきます。


💡 Geminiからの例え話

「HTMLで作った canvas はただの 『真っ白なキャンバス(画板)』。でもそれだけじゃ絵は描けないよね?

だからJavaScriptで 『ctx(魔法の筆)』 を用意して、自由自在に絵や図形を描き込めるように準備しているんだよ!」



5. 音の魔術師(playSound関数)

先ほど少し触れましたが、コードの並び順ではここに来ます。

function playSound(type) {

        if (!audioCtx) return;

        const osc = audioCtx.createOscillator();

        const gain = audioCtx.createGain();

        osc.connect(gain); gain.connect(audioCtx.destination);


function playSound(type) の役割

この 1 行は、「 playSound という名前の『音を鳴らす魔法の呪文』を今から作りますよ!」 という宣言です。


function (関数):

何度も使う一連の命令に名前をつけて、ひとまとめにしたものです。

今回なら「楽器を用意して、音の高さを変えて、音量を絞って……」という複雑な手順を、「音を鳴らせ!」 という一言で実行できるようにしています。


(type) (引数/ひきすう):

これがとっても重要です!これは魔法の呪文に添える 「付け足しの言葉」 です。

ただ「音を鳴らせ」と言うだけでは、ジャンプの音なのか、コインの音なのか分かりませんわよね?

そこで、

・playSound('jump') と言えば、ジャンプの音が鳴る

・playSound('coin') と言えば、コインの音が鳴る

という風に、( )の中身を見て、鳴らす音の種類を切り替えられる ようにしているんです。


osc.connect(gain); gain.connect(...):

これは「楽器」と「スピーカー」をコードで繋いでいるイメージですわ!

osc(楽器)で作った音を

gain(音量調整つまみ)に通して

destination(スピーカー)から出す

という回路を、その瞬間にパパッと組み立てているんです。


💡 Geminiからの例え話

「 function は、いわば 『全自動のジュークボックス』 を作るようなものだよ。

中にいろんなレコードが入っていて、外から 『type(曲名)』 を教えてあげるだけで、その音を正しく選んで鳴らしてくれる。

一度これを作っておけば、あとはゲームのあちこちで『playSound!』と叫ぶだけで、状況に合わせた音が鳴るようになるんだね!」



6. ゲームの状態(変数)と初期化

ここから、ゲーム内で使う「数字のメモ(変数)」がずらっと並びます。

let state = 'START', worldX = 0, score = 0, gravity = 0.8, groundY = 320;


let (レット):

「今から新しい『メモ書き(変数)』を作りますよ!」という合図。


state (ステート):

ゲームの「状態」を記録します。今はタイトル画面(START)なのか、遊んでいる最中(PLAY)なのか、はたまたゴールした(GOAL)のかを、この単語を見て判断します。


worldX (ワールド・エックス):

右にどれだけ進んだかという「スクロールの量」です。これがあるおかげで、背景が動いて見えるんです。


score (スコア):

おなじみの「得点」です。最初は 0 から始まります。


gravity (グラビティ):

「重力」です。この数字を大きくすると、主人公がズドン!と速く落ちるようになります。


groundY (グラウンド・ワイ):

「地面の高さ」です。これ以上下にいかないための境界線を数字で決めています。

    let p = { x: 100, y: 300, vx: 0, vy: 0, dir: 1, walk: 0, ground: true, canJump: true };


ここでは p (プレイヤー) という一つのグループの中に、主人公の細かい情報をまとめて詰め込んでいます。これを「オブジェクト」と呼びます。


x, y: 

主人公の今の場所(座標)です。


vx, vy: 

主人公の「動くスピード」です。vは速度(Velocity)の頭文字です。


dir (ディレクション):

向いている「向き」です。右なら 1、左なら -1 として、画像の向きを反転させるのに使います。


walk: 

足をバタバタさせるアニメーション用のカウンターです。


ground: 

「今、地面に足がついてる?」というYes/Noの情報です。空中ではジャンプできないようにするための判定に使います。


canJump: 

「今、ジャンプボタンを押していい?」という判定用です。

let enemies = [], boxes = [], goalX = 4000;


enemies (エネミーズ) / boxes (ボックス):

中身が空っぽの [ ] (配列) を用意しています。ここに、後でたくさんの敵キャラクターや宝箱の情報を追加していく「リスト」のようなものです。


goalX: 

「どこまで行ったらゴールか」という終わりの距離(4000ピクセル先!)を決めています。


keys = {};

「今、どのキーが押されているか」を記録するための、空のリストです。


💡 Geminiからの例え話

「プログラムはとっても忘れん坊。だから、『今は0点だよ』『主人公はここにいるよ』 と、常に最新の状態をメモ(変数)に書いておかないといけないんだ。

この 6 番のエリアは、ゲームが始まる前に 『これから使うメモ帳を全部机に並べて、最初の数字を書き込んだ』 という状態だね!」



ここまで解説してみてなんですが、これ全部解説してたら1冊の本が出来上がってしまいますね(^^;

という事で申し訳ないのですが、解説は一旦ここで区切らせていただきます。

もしどうしてもここが気になる!という人がいましたら、Gemini、もしくはCopilotなどに、この命令文は何をしてるの?と聞いてみてください。

結構分かりやすく解説してくれます。


もしまたやる気が出てきましたら続きを作ろうかとも思うのですが、すっごい長いコードが細かく続くので…余程の事が無い限り続きを作る気力がおきないかもしれません(^^;


ちなみにこの記事で載せてるプログラムコードですが、あなたのブログ記事で改造したバージョンを載せて遊んでもらっても構いません。

ただその時はここの記事のURLの記述もお願いします。


0 件のコメント:

コメントを投稿

I'm sorry, Japanese text only.
荒らし目的と思われるコメントは気づき次第対処します。