この記事はアド"定期ゲ"ベントカレンダー2025 Advent Calendar 2025 14日目の参加記事です。
はじめに
はじめまして、あすかパンです。
2024年秋ごろに突如ゲームを作ってみようとティラノビルダーに触れ、そのままunityやUE5を恐る恐るなでなでと触れている者です。触れてゲームを作っているけど、unity製UE5製のゲームは動画をちょこっと出したくらいでプレイできる環境で出したことはない…そんなレベルです。2026年は他人が遊べる形で配布したい…。いや、もはや売ってお金を得たい(人間は欲深いので)。そんな気持ちがあります。
unityに触れ、初心者用C#教本(ふりがなプログラミングC#)を学び終え、「今なら以前挫折したjsvascriptもちゃんと学べるんじゃないか? 積んでる教本もあるし、chatGPTもいるし」と思い立ちまして、わかりやすい締切としてアドカレに参加させていただきました。
以前javascriptに触れたのは5年以上前だった気がする。まだ関数という概念もふわっふわの認識で、AIのサポートも無いので学習難易度が高くて……。今はyoutubeの動画も有るし、ありがたい時代だ……。
アドカレ参加というわかりやすい締切をもらい、せっかくならjavascriptでなにかを作ろう! ということでゆめにっきのnasuみたいなキャッチゲームを作っていこうと思います。ふりがなプログラミングjavascriptを学び終えた俺は無敵だ……!
えっ、canvas? fillRect? ホギャギャー!!!!??(未知のメソッドに不意をつかれ、後方20mほど吹き飛ばされる)
この記事には以下の内容が含まれます
- 読者を鑑みないノリ
- javascript初学者の浅い知識(間違ってる部分があるかも)
- いきなり現れた謎のcanvas
- chatGPTの答えベタ貼り
- 脱線
ありがとうふりがなプログラミング、ありがとうchatGPT、ありがとうyoutube、ありがとう勉強動画を出してくれる人々。
夢(脱線)
書きたいことを書いていくとすぐ脱線する。
javascript、学んだほうがいいんだよな~とは思いつつ「でもゲームだとブラウザ用のミニゲームくらいしか作れないのでは」「webサイト作成メインじゃないから学んでも使う機会無くてすぐ忘れちゃいそう」とか考えて腰を重くしていたのですが、そういえばティラノスクリプトでjavascript使えるじゃん!
chatGPTいわく、javascriptで作ったミニゲームはティラノスクリプトにまるっと入れられるらしいです。えっ、すげー! 流れてくる文字をクリックで撃つような、簡易ダンガンロンパみたいなのもできるってこと!? 夢が広がる! ティラノスクリプトだけでミニゲーム作るのって大変だから…(1敗)
javascriptでゲーム作るならPhaserというjsフレームワーク(Phaserエディターもある!)があると聞いたんですが、それと組み合わせればもうティラノスクリプトめちゃくちゃにできちゃうんじゃないですか? って思ったけどやってる人いないのか…? Xの成功報告ポストが一件出てくるけど削除済みらしく詳細はわからず…。気になるな…。
今年のGoogleのハロウィンゲーム(パックマン)ってphaserで作られてたらしい。意外と身近だ。
やっていこう
ふりがなプログラミングjavascriptを学び終えた! canvasの超基礎知識も入れた! 次は何をすればいいんだ、chatGPT!
ってchatGPTに言ったらゆめにっきのnasu的ゲームのjsコード全部出してくれたので、大人しくこれを1行ずつ見ていって学びを得ます。これが令和の勉強法か……。
完成したものがこちらになります
ゆめにっきのnasu的ゲームです。当たり判定が自機全体にあるからnasuよりは簡単。
chatGPTにめちゃくちゃ助けてもらっています。ありがとう、chatGPT。
javascriptコードの中身
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
//----------------------------------
//ゲームループ
//----------------------------------
function gameLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (gameState === "title") {
drawBackground();
drawTitle();
} else if (gameState === "play") {
drawBackground(); // 01: 背景描画
updatePlayer(); // 02: プレイヤー更新
updateAnimation(); // 03: アニメーション更新
updateItem() ; // 04: アイテム更新
checkCollision(); // 05: 当たり判定チェック
drawPlayer(); // 06: プレイヤー描画
drawItem() ; // 07: アイテム描画
drawScore(); // 08: スコア描画
drawLife(); // 09: ライフ描画
} else if(gameState === "gameover"){
drawBackground();
drawItem();
drawPlayer();
drawLife();
drawGameOver(); // ← 止まった上に表示
}
requestAnimationFrame(gameLoop); // 次のフレームで再度実行
}
//----------------------------------
//タイトル画面
//----------------------------------
//どの画面に居るか判定(初期値:タイトル画面)
let gameState = "title";
//タイトル画面描画関数
function drawTitle() {
ctx.fillStyle = "#000";
ctx.font = "32px sans-serif";
ctx.textAlign = "center";
ctx.fillText("ドーナツキャッチゲーム", canvas.width / 2, 200);
ctx.font = "16px sans-serif";
ctx.fillText("パソコン:← → キーで移動。スペースキーでジャンプ", canvas.width / 2, 260);
ctx.fillText("スマホ:画面左・右を押して移動。タッチでジャンプ", canvas.width / 2, 290);
ctx.fillText("落ちてくるドーナツをキャッチしよう!", canvas.width / 2, 320);
ctx.fillText("※BGMが流れます", canvas.width / 2, 350);
// スタートボタン
ctx.fillStyle = "rgba(113, 113, 161, 1)";
ctx.fillRect(canvas.width / 2 - 75, 380, 150, 40);
ctx.fillStyle = "#fff";
ctx.font = "20px sans-serif";
ctx.fillText("START", canvas.width / 2, 408);
}
//スタートボタン判定
const startButton = {
x: canvas.width / 2 - 75,
y: 380,
width: 150,
height: 40
};
canvas.addEventListener("click", (e) => {
if (gameState !== "title") return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (
mouseX >= startButton.x &&
mouseX <= startButton.x + startButton.width &&
mouseY >= startButton.y &&
mouseY <= startButton.y + startButton.height
) {
startGame();
}
});
//ゲーム開始処理
function startGame() {
score = 0;
item.y = -50;
gameState = "play";
bgm.currentTime = 0;
bgm.play();
}
//----------------------------------
//メインゲーム画面
//----------------------------------
//プレイヤーを作る
let player = {
x: 175,
y: 500,
width: 50,
height: 50,
speed: 5,
direction: 1
};
let keys = {};
document.addEventListener("keydown", e => keys[e.key] = true);
document.addEventListener("keyup", e => keys[e.key] = false);
//------------------
//スマホ用タッチ操作
//------------------
//タッチ用変数
let touchLeft = false;
let touchRight = false;
//タッチ開始
canvas.addEventListener("touchstart", (e) => {
if (gameState !== "play") return;
e.preventDefault(); // スクロール防止
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
if (x < canvas.width / 2) {
touchLeft = true;
} else {
touchRight = true;
}
// タップでジャンプ
if (player.onGround) {
player.vy = -JUMP_POWER;
player.onGround = false;
}
}, { passive: false });
//タッチ終了
canvas.addEventListener("touchend", () => {
touchLeft = false;
touchRight = false;
});
//------------------
//左右移動+向きを更
//ジャンプ機能
//------------------
//縦の速度を定義
player.vy = 0; // 縦方向の速度
player.onGround = true;
const GRAVITY = 0.5;
const JUMP_POWER = 10;
const GROUND_Y = player.y; // 地面のy座標(今のplayer.y)
function updatePlayer() {
// 左右移動(今までの)
if (keys["ArrowLeft"] || touchLeft) {
player.x -= player.speed;
player.direction = 1;
}
if (keys["ArrowRight"] || touchRight) {
player.x += player.speed;
player.direction = -1;
}
// ジャンプ(スペースキー)
if (keys[" "] && player.onGround) {
player.vy = -JUMP_POWER;
player.onGround = false;
seJump.currentTime = 0;
seJump.play();
}
// 重力
player.vy += GRAVITY;
player.y += player.vy;
// 地面判定
if (player.y >= GROUND_Y) {
player.y = GROUND_Y;
player.vy = 0;
player.onGround = true;
}
// 画面外防止(左右)
player.x = Math.max(0, Math.min(canvas.width - player.width, player.x));
}
//------------------
//プレイヤーに関するいろいろ
//------------------
//アニメーション準備
const playerImages = [];
let playerImageLoaded = 0;
for (let i = 1; i <= 2; i++) {
const playerimg = new Image();
playerimg.onload = () => playerImageLoaded++;
playerimg.src = `img/playerIdle${i}.png`;
playerImages.push(playerimg);
}
//ジャンプ用画像準備
const jumpImage = new Image();
let jumpImgLoaded = false;
jumpImage.onload = () => {
jumpImgLoaded = true;
};
jumpImage.src = "img/playerJump01.png";
let currentFrame = 0; // 0 or 1
let frameCount = 0; // フレーム用カウンタ
//Frame切り替えロジック
function updateAnimation() {
frameCount++;
// 10フレームごとに切り替え(数値で速さ調整)
if (frameCount % 10 === 0) {
currentFrame = (currentFrame + 1) % 2;
}
}
//プレイヤー描画
function drawPlayer() {
ctx.save();
ctx.translate(player.x + player.width / 2, player.y);
ctx.scale(player.direction, 1);
if (!player.onGround && jumpImgLoaded) {
// ジャンプ中
ctx.drawImage(
jumpImage,
-player.width / 2,
0,
player.width,
player.height
);
} else {
// 地上(通常アニメ)
ctx.drawImage(
playerImages[currentFrame],
-player.width / 2,
0,
player.width,
player.height
);
}
ctx.restore();
}
//------------------
//アイテムに関するいろいろ
//------------------
//落ちてくるアイテムを作る
const itemImg = new Image();
itemImg.src = "img/donut01.png";
let itemImgLoaded = false; //画像が読み込まれているか?
itemImg.onload = () => { //画像読み込み完了時に実行
itemImgLoaded = true;
};
const ITEM_SIZE = 50;
let item = {
x: Math.random() * (canvas.width - ITEM_SIZE),
y: -50,
size: ITEM_SIZE,
speed: 3
};
//アイテムリセット関数
function resetItem(){
item.x = Math.random() * (canvas.width - item.size);
item.y = -50;
}
function updateItem() {
item.y += item.speed;
// 下に落ちきったらリセット
if (item.y > canvas.height) {
//ライフに関する処理(落ちたらライフ1減る)
life--;
seMiss.currentTime = 0;
seMiss.play();
if (life <= 0) {
gameOver();
return;
}
//アイテムリセット処理
resetItem()
}
}
function drawItem() {
if (itemImgLoaded) {
ctx.drawImage(itemImg, item.x, item.y, item.size, item.size);
} else {
ctx.fillStyle = "green";
ctx.fillRect(item.x, item.y, item.size, item.size);
}
}
//------------------
//スコアに関するいろいろ
//------------------
//スコア表示用
let score = 0;
function drawScore() {
ctx.fillStyle = "black";
ctx.font = "20px sans-serif";
ctx.fillText(`SCORE: ${score}`, 60, 35);
}
//------------------
//当たり判定に関するいろいろ
//------------------
//当たり判定
function checkCollision() {
if (item.x < player.x + player.width &&
item.x + item.size > player.x &&
item.y < player.y + player.height &&
item.y + item.size > player.y) {
score += 1;
seCatch.currentTime = 0;
seCatch.play();
// キャッチ成功 → リセット
resetItem()
}
}
//------------------
//背景描画に関するいろいろ
//------------------
//描画準備
const bgImage = new Image();
let bgLoaded = false;
bgImage.onload = () => {
bgLoaded = true;
};
bgImage.src = "img/haikei.png";
//背景描画用関数
function drawBackground() {
if (bgLoaded) {
ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
} else {
// 読み込み前は仮背景
ctx.fillStyle = "#ccf";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
//----------------------------------
//ゲームオーバーに関するいろいろ
//----------------------------------
//ライフの変数
const MAX_LIFE = 3;
let life = MAX_LIFE;
//ハート画像準備
const heartImg = new Image();
let heartLoaded = false;
heartImg.onload = () => {
heartLoaded = true;
};
heartImg.src = "img/heart.png";
//ハート描画関数
function drawLife() {
if (!heartLoaded) return;
const size = 50;
const margin = 10;
for (let i = 0; i < life; i++) {
ctx.drawImage(
heartImg,
canvas.width - (size + margin) * (i + 1),
margin,
size,
size
);
}
}
//ゲームオーバー処理
function gameOver() {
gameState = "gameover";
}
//リスタート用処理
function resetGame() {
score = 0;
life = MAX_LIFE;
gameState = "title";
resetItem();
player.x = 175;
player.y = 500;
player.vy = 0;
player.onGround = true;
keys = {}; // 入力状態を全部リセット
bgm.pause(); // BGM停止
}
//ゲームオーバー画面
function drawGameOver() {
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#fff";
ctx.font = "36px sans-serif";
ctx.textAlign = "center";
ctx.fillText("GAME OVER", canvas.width / 2, canvas.height / 2 - 20);
ctx.font = "20px sans-serif";
ctx.fillText(`SCORE: ${score}`, canvas.width / 2, canvas.height / 2 + 20);
ctx.font = "20px sans-serif";
ctx.fillText(`画面をクリックでリスタート`, canvas.width / 2, canvas.height / 2 + 60);
}
//クリックしたらリスタート
const restartButton = {
x: 0,
y: 0,
width: canvas.width,
height: canvas.height
};
canvas.addEventListener("click", () => {
if (gameState === "gameover") {
resetGame();
}
});
//----------------------------------
//BGM・効果音いろいろ
//----------------------------------
//BGM準備
const bgm = new Audio("bgm_se/kamatamago_BGM1.mp3");
bgm.loop = true;
bgm.volume = 0.5;
//se準備:ジャンプ(効果音ラボ:水滴2)
const seJump = new Audio("bgm_se/koukaonLabo_se1.mp3");
//se準備:キャッチ(効果音ラボ:食べ物をパクッ)
const seCatch = new Audio("bgm_se/koukaonLabo_se2.mp3");
//se準備:ミス(効果音ラボ:キャンセル4)
const seMiss = new Audio("bgm_se/koukaonLabo_se3.mp3");
//----------------------------------
//ゲームループ開始
//----------------------------------
gameLoop();
見ていこう
まずは最初の画面構成のところ。
//----------------------------------
//ゲームループ
//----------------------------------
function gameLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (gameState === "title") {
drawBackground();
drawTitle();
} else if (gameState === "play") {
drawBackground(); // 01: 背景描画
updatePlayer(); // 02: プレイヤー更新
updateAnimation(); // 03: アニメーション更新
updateItem() ; // 04: アイテム更新
checkCollision(); // 05: 当たり判定チェック
drawPlayer(); // 06: プレイヤー描画
drawItem() ; // 07: アイテム描画
drawScore(); // 08: スコア描画
drawLife(); // 09: ライフ描画
} else if(gameState === "gameover"){
drawBackground();
drawItem();
drawPlayer();
drawLife();
drawGameOver(); // ← 止まった上に表示
}
requestAnimationFrame(gameLoop); // 次のフレームで再度実行
}
関数呼び出しまみれだ。これがオブジェクト指向ってこと…!?
描画順
このcanvasとかいうの、描画順指定(cssでいうz-indexとか)がないからシンプルに下層からどんどん描画レイヤーが積み上がっていく形式になっているらしい。最初にdrawBackground(); (背景描画する関数)を置いとかないと背景が背景になってくれない!
updateされないと止まる
gameState === "play”のときにはupdate(更新)系があり、gameState === "gameover")のときにはupdate系はなし。これをすると、ゲームオーバー時に画面が更新されず良い感じに画面が停止状態になってくれる! プレイヤーの状態を更新するupdatePlayer();の中に左右移動の処理も入っているので、それも止まる! いいね~。
タイトル画面
//----------------------------------
//タイトル画面
//----------------------------------
//どの画面に居るか判定(初期値:タイトル画面)
let gameState = "title";
//タイトル画面描画関数
function drawTitle() {
ctx.fillStyle = "#000";
ctx.font = "32px sans-serif";
ctx.textAlign = "center";
ctx.fillText("ドーナツキャッチゲーム", canvas.width / 2, 200);
ctx.font = "16px sans-serif";
ctx.fillText("パソコン:← → キーで移動。スペースキーでジャンプ", canvas.width / 2, 260);
ctx.fillText("スマホ:画面左・右を押して移動。タッチでジャンプ", canvas.width / 2, 290);
ctx.fillText("落ちてくるドーナツをキャッチしよう!", canvas.width / 2, 320);
ctx.fillText("※BGMが流れます", canvas.width / 2, 350);
// スタートボタン
ctx.fillStyle = "rgba(113, 113, 161, 1)";
ctx.fillRect(canvas.width / 2 - 75, 380, 150, 40);
ctx.fillStyle = "#fff";
ctx.font = "20px sans-serif";
ctx.fillText("START", canvas.width / 2, 408);
}
//スタートボタン判定
const startButton = {
x: canvas.width / 2 - 75,
y: 408,
width: 150,
height: 40
};
canvas.addEventListener("click", (e) => {
if (gameState !== "title") return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (
mouseX >= startButton.x &&
mouseX <= startButton.x + startButton.width &&
mouseY >= startButton.y &&
mouseY <= startButton.y + startButton.height
) {
startGame();
}
});
//ゲーム開始処理
function startGame() {
score = 0;
item.y = -50;
gameState = "play";
bgm.currentTime = 0;
bgm.play();
}
上から
- どの画面(タイトル画面・メインゲーム画面・ゲームオーバー画面)にいるかを取得
- タイトルとか説明文とかボタンとかの描画
- スタートボタンの判定
- ゲームスタートボタン押した時の処理
canvas.width / 2
もしかして、これって様々な場面で使える便利な中央揃えなのでは…。canvasに限らず、ティラノスクリプトとかでも画面幅 / 2で良い感じに使えるのでは…!? こんな簡単なことだったのか。アハ体験だ。
ボタンをクリックしたらスタート
canvas.addEventListener("click", (e) => {
if (gameState !== "title") return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (
mouseX >= startButton.x &&
mouseX <= startButton.x + startButton.width &&
mouseY >= startButton.y &&
mouseY <= startButton.y + startButton.height
) {
startGame();
}
});
これなに? 言いたいことはわかるけど、なんかマインクラフトの不思議なギミックを見ている気分だ。
chatGPT氏曰く「超短いまとめ(アドカレ用)」
📌
eは「クリックイベントの情報」e.clientXは「ブラウザ基準のX座標」- canvas内で使うには座標変換が必要
getBoundingClientRect()とセットで使う
アドカレ記事的には、
「e.clientX は“生の座標”なので、canvas用に変換して使っている」
って書けたら、かなり理解度高い解説になりますよ 👍
canvas上のみの座標を求めるのなんかめんどくさくない!? この決まった型コピペすればいいだろ的な、そういうやつなのかな…。
というか、ボタン自体の座標を勝手に取得してくれるとか無いんだ。HTMLとCSSはdivとかボタンにしてクリックできるのに。
メインゲーム画面
ちょっと長い。
//プレイヤーを作る
let player = {
x: 175,
y: 500,
width: 50,
height: 50,
speed: 5,
direction: 1
};
プレイヤーの数値の初期設定。これ、なんとなく流しちゃってたけど予約語じゃないんですね。xもyも、このコードの中で勝手に縦横の距離として扱ってるだけで、別にjavascript自体はそういう扱いをしているわけではないというか…。ここのwidthは、CSSのwidthとはまた違うというか…。
//------------------
//スマホ用タッチ操作
//------------------
//タッチ用変数
let touchLeft = false;
let touchRight = false;
//タッチ開始
canvas.addEventListener("touchstart", (e) => {
if (gameState !== "play") return;
e.preventDefault(); // スクロール防止
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
if (x < canvas.width / 2) {
touchLeft = true;
} else {
touchRight = true;
}
// タップでジャンプ
if (player.onGround) {
player.vy = -JUMP_POWER;
player.onGround = false;
}
}, { passive: false });
//タッチ終了
canvas.addEventListener("touchend", () => {
touchLeft = false;
touchRight = false;
});
作り終わった時に「このゲームスマホ未対応じゃん」と気づいて、突貫でつけてもらったもの。
ここだけ全然理解してない。呪文? ゲーム作り終えて疲れて「まぁ動くからいいか…」となっています。イベントってタッチ判定もあるんだ…。
//------------------
//左右移動+向きを更
//ジャンプ機能
//------------------
//縦の速度を定義
player.vy = 0; // 縦方向の速度
player.onGround = true;
const GRAVITY = 0.5;
const JUMP_POWER = 10;
const GROUND_Y = player.y; // 地面のy座標(今のplayer.y)
function updatePlayer() {
// 左右移動(今までの)
if (keys["ArrowLeft"] || touchLeft) {
player.x -= player.speed;
player.direction = 1;
}
if (keys["ArrowRight"] || touchRight) {
player.x += player.speed;
player.direction = -1;
}
// ジャンプ(スペースキー)
if (keys[" "] && player.onGround) {
player.vy = -JUMP_POWER;
player.onGround = false;
seJump.currentTime = 0;
seJump.play();
}
// 重力
player.vy += GRAVITY;
player.y += player.vy;
// 地面判定
if (player.y >= GROUND_Y) {
player.y = GROUND_Y;
player.vy = 0;
player.onGround = true;
}
// 画面外防止(左右)
player.x = Math.max(0, Math.min(canvas.width - player.width, player.x));
}
unityでもジャンプの処理を人任せにしていました。重力って何…? 毎フレーム、重力分を上方向への移動から引いて放射線状にジャンプさせているらしい。????
### chatGPT氏曰く(コピペ)
ここは **「ジャンプ=物理っぽく見えるけど、実は超シンプル」**な所なので、
上から順に“何が起きているか”を時間の流れで説明しますね。
まず全体を一言で言うと
このコードは
「地面にいる時だけ上向きの速度を与えて、
あとは毎フレーム重力で落とし、
地面に着いたら止める」
という処理です。
① ジャンプ入力(きっかけ)
if (keys[" "] && player.onGround) {
player.vy = -JUMP_POWER;
player.onGround = false;
seJump.currentTime = 0;
seJump.play();
}
ここでやっていること
-
keys[" "]→ スペースキーが押されている?
-
player.onGround→ 地面にいる?
👉 両方 true のときだけジャンプ可能
player.vy = -JUMP_POWER;
ここがジャンプの正体です。
-
vy= 縦方向の速度 - Canvasでは
- 上に行くほど y が小さくなる
- だから
- 上向き = マイナス
vy = -10 ← 上に動く
vy = 0 ← 止まる
vy = +10 ← 下に落ちる
player.onGround = false;
- 空中にいる状態にする
- これがあるから 二段ジャンプできない
SE 再生
seJump.currentTime = 0;
seJump.play();
-
ジャンプした瞬間に1回だけ鳴る
-
update内だけど
条件付きなので問題なし
② 重力(毎フレーム必ず実行)
player.vy += GRAVITY;
player.y += player.vy;
ここが一番大事です。
まず player.vy += GRAVITY;
- 重力は 下向き
- だから vy を少しずつ増やす
例(GRAVITY = 0.5):
-10 → -9.5 → -9 → -8.5 → ... → 0 → 0.5 → 1 → 1.5 ...
👉
- 最初は上に行く
- だんだん減速
- 0で頂点
- そこから落下
次に player.y += player.vy;
- 現在の速度分だけ位置を動かす
player.y = player.y + player.vy;
これを 毎フレーム繰り返すことで
「放物線っぽいジャンプ」になります。
③ 地面判定(着地)
if (player.y >= GROUND_Y) {
player.y = GROUND_Y;
player.vy = 0;
player.onGround = true;
}
何をしている?
- 地面より下に行った?
- 行ってたら…
player.y = GROUND_Y;
- めり込み防止
- 必ず地面ピッタリに戻す
player.vy = 0;
-
落下速度を止める
-
これがないと
地面についても永遠に落ち続ける
player.onGround = true;
- 「地面にいる状態」に戻す
- 次のジャンプが可能になる
🔄 フレーム単位で見るとこう
フレーム0(ジャンプ入力)
vy = -10
y = 500
フレーム1
vy = -9.5
y = 490.5
フレーム5(頂点付近)
vy = 0
y = 一番高い
フレーム10(落下)
vy = 3
y = だんだん増える
地面到達
y >= GROUND_Y
→ y = GROUND_Y
→ vy = 0
→ onGround = true
🎮 Unityと完全対応させると
これはほぼこれです👇
if (isGrounded && Input.GetKeyDown(KeyCode.Space)) {
rb.velocity = new Vector2(rb.velocity.x, jumpPower);
}
rb.velocity += Vector2.down * gravity * Time.deltaTime;
👉 Rigidbody を自分で書いているだけ
超短いまとめ
- ジャンプは「yを直接動かしていない」
- 速度(vy)を動かしている
- 重力は「vyを増やす」
- 地面で vy を 0 に戻す
- onGround は状態管理用
ここまで理解できたら
このジャンプ処理は
もう自分のものです。
次に自然に気になるのは👇
- ジャンプ中は操作を弱める
- 長押しでジャンプ高さ変える
- 落下中だけ速くする
どれも、このコードの延長線上です 👍
chatGPTにすべてを任せていくからな。AI生成ベタ貼り記事を笑えんぞ、これは。
//------------------
//プレイヤーに関するいろいろ
//------------------
//アニメーション準備
const playerImages = [];
let playerImageLoaded = 0;
for (let i = 1; i <= 2; i++) {
const playerimg = new Image();
playerimg.onload = () => playerImageLoaded++;
playerimg.src = `img/playerIdle${i}.png`;
playerImages.push(playerimg);
}
自機画像2コマアニメをしたかった部分。誰が考えたのか知らないけど、よくこんなロジック思いつくな。一休さんと互角に戦えるぞ。
//プレイヤー描画
function drawPlayer() {
ctx.save();
ctx.translate(player.x + player.width / 2, player.y);
ctx.scale(player.direction, 1);
if (!player.onGround && jumpImgLoaded) {
// ジャンプ中
ctx.drawImage(
jumpImage,
-player.width / 2,
0,
player.width,
player.height
);
} else {
// 地上(通常アニメ)
ctx.drawImage(
playerImages[currentFrame],
-player.width / 2,
0,
player.width,
player.height
);
}
ctx.restore();
}
ctx.save(); と ctx.restore();:プレイヤー描画部分になんか大事らしい。
ctx.translate(player.x + player.width / 2, player.y); と ctx.scale(player.direction, 1);:ctx.translateでキャンバスの原点を自機の中心近くにして、ctx.scaleで反転させてるらしい
アイテム処理とか
//アイテムリセット関数
function resetItem(){
item.x = Math.random() * (canvas.width - item.size);
item.y = -50;
}
unityだとオブジェクトを消すにはdestroyが必要ですよね。これには画像を消すような処理は入ってないけど、なぜ…。A、一つのオブジェクトを画面上に移動させてるから。だそうです。
じゃ、画面上に3つとか降らせたかったら3つ書かなきゃいけないの? A、その場合は配列で処理する、だそうです。
### chatGPT氏曰く
正解の考え方:配列にする
① アイテム配列を作る
let items = [];
② 最初に3つ作る
for (let i = 0; i < 3; i++) {
items.push({
x: Math.random() * (canvas.width - ITEM_SIZE),
y: Math.random() * -300, // ちょっとずらす
size: ITEM_SIZE,
speed: ITEM_SPEED
});
}
👉 オブジェクトは3つだけ作って、使い回す
③ updateItem を配列対応に
function updateItems() {
for (let item of items) {
item.y += item.speed;
if (item.y > canvas.height) {
item.x = Math.random() * (canvas.width - item.size);
item.y = -50;
}
}
}
④ drawItem も配列で
function drawItems() {
for (let item of items) {
ctx.drawImage(itemImage, item.x, item.y, item.size, item.size);
}
}
なぜ配列が「正解」なのか
理由①:数を簡単に変えられる
for (let i = 0; i < 10; i++) { ... }
に変えるだけで
10個降る。
理由②:処理を1か所で書ける
- 移動
- 当たり判定
- リセット
👉 全部ループで回せる。
理由③:Unityと同じ考え方
Unityでも実は👇
List<Item> items;
ですよね。
Canvas(JS)は
それを自分で書いているだけ。
この.pushっていうやつ、配列使う時によく出てくるな。もしかしてめちゃくちゃ使う便利なやつなのか?
ここらへんまで記事書いたけど、もう眠くなってきてる。このアドカレは14日分。現在時刻は14日20:22。もうちょっと頑張るか。素人の備忘録だから、chatGPTベタ貼りでも許してくれ。
当たり判定のこれ、なんだよ…
//------------------
//当たり判定に関するいろいろ
//------------------
//当たり判定
function checkCollision() {
if (item.x < player.x + player.width &&
item.x + item.size > player.x &&
item.y < player.y + player.height &&
item.y + item.size > player.y) {
score += 1;
seCatch.currentTime = 0;
seCatch.play();
// キャッチ成功 → リセット
resetItem()
}
}
if (item.x < player.x + player.width &&
item.x + item.size > player.x &&
item.y < player.y + player.height &&
item.y + item.size > player.y) {
これ。ボタン判定と記述がにてるな。自機画像の範囲内にアイテム(ドーナツ)画像が入ったらスコア+してリセットという感じだと思う。当たり判定がこんなif文の条件で書かれる。chatGPT氏いわく「AABB(Axis-Aligned Bounding Box)判定」と呼ばれるらしい。へ~。
背景描画
//------------------
//背景描画に関するいろいろ
//------------------
//描画準備
const bgImage = new Image();
let bgLoaded = false;
bgImage.onload = () => {
bgLoaded = true;
};
bgImage.src = "img/haikei.png";
//背景描画用関数
function drawBackground() {
if (bgLoaded) {
ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
} else {
// 読み込み前は仮背景
ctx.fillStyle = "#ccf";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
特筆することもなさそうだけどcanvasで画像を扱う備忘録として書いておく。
const bgImage = new Image(); これで画像準備
let bgLoaded = false; これで画像読み込み済みか判定(初期値はfalse)
bgImage.onload = () => {
bgLoaded = true;
}; このonloadで背景画像が読み込まれてたら読み込み判定をtrueに
bgImage.src = "img/haikei.png"; 背景画像のソース。
function drawBackground() {
if (bgLoaded) {
ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
もし画像が読み込まれてたら指定の描画をして
} else {
// 読み込み前は仮背景
ctx.fillStyle = "#ccf";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
読み込まれてなかったら仮の単色画像を表示
ゲームオーバーとか
ライフとか
//ライフの変数
const MAX_LIFE = 3;
let life = MAX_LIFE;
//ハート画像準備
const heartImg = new Image();
let heartLoaded = false;
heartImg.onload = () => {
heartLoaded = true;
};
heartImg.src = "img/heart.png";
//ハート描画関数
function drawLife() {
if (!heartLoaded) return;
const size = 50;
const margin = 10;
for (let i = 0; i < life; i++) {
ctx.drawImage(
heartImg,
canvas.width - (size + margin) * (i + 1),
margin,
size,
size
);
}
}
const MAX_LIFE = 3;
let life = MAX_LIFE;
constとletだ…。変更しない変数を変更できる変数に突っ込んでいる…。ややこしい、が、これが正しいのかも。
for文の例題見るたびに「一休さんめ…」となっています。
リスタートの処理
//リスタート用処理
function resetGame() {
score = 0;
life = MAX_LIFE;
gameState = "title";
resetItem();
player.x = 175;
player.y = 500;
player.vy = 0;
player.onGround = true;
keys = {}; // 入力状態を全部リセット
bgm.pause(); // BGM停止
}
これstartGame();と一部内容かぶってるな。なんかうまいこと統合できそうだけど…。
chatGPT曰く、役割分担は必要だけど共通部分だけなら中間素材ならぬ中間関数にしてしまっていいらしい。マジでマインクラフトじゃん! インゴッド作ったほうがいいか!? 次回から気をつけます。
ゲームオーバー画面
//ゲームオーバー画面
function drawGameOver() {
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#fff";
ctx.font = "36px sans-serif";
ctx.textAlign = "center";
ctx.fillText("GAME OVER", canvas.width / 2, canvas.height / 2 - 20);
ctx.font = "20px sans-serif";
ctx.fillText(`SCORE: ${score}`, canvas.width / 2, canvas.height / 2 + 20);
ctx.font = "20px sans-serif";
ctx.fillText(`画面をクリックでリスタート`, canvas.width / 2, canvas.height / 2 + 60);
}
//クリックしたらリスタート
const restartButton = {
x: 0,
y: 0,
width: canvas.width,
height: canvas.height
};
canvas.addEventListener("click", () => {
if (gameState === "gameover") {
resetGame();
}
});
今更だけど、fillStyleってアルファも扱えるんですね。無敵だぁ。
BGM・効果音の準備
//----------------------------------
//BGM・効果音いろいろ
//----------------------------------
//BGM準備
const bgm = new Audio("bgm_se/kamatamago_BGM1.mp3");
bgm.loop = true;
bgm.volume = 0.5;
//se準備:ジャンプ(効果音ラボ:水滴2)
const seJump = new Audio("bgm_se/koukaonLabo_se1.mp3");
//se準備:キャッチ(効果音ラボ:食べ物をパクッ)
const seCatch = new Audio("bgm_se/koukaonLabo_se2.mp3");
//se準備:ミス(効果音ラボ:キャンセル4)
const seMiss = new Audio("bgm_se/koukaonLabo_se3.mp3");
ありがとうかまタマゴさん、ありがとう効果音ラボさん。
音楽流す系、めちゃくちゃシンプルofシンプルで助かった。
seCatch.currentTime = 0;
seCatch.play();
これだけで効果音を流せる。
seCatch.currentTime = 0;
これは音ズレなどを修正するため、「0秒から開始」という意味らしいです。
bgm.loop = true;
BGMはこれだけでループ再生してくれる。これができてなぜボタン範囲自動取得ができないんだ…。不思議だ。
//----------------------------------
//ゲームループ開始
//----------------------------------
gameLoop();
functionとアロー関数
これなんか使い分けられてるよな…。
### chatGPT氏曰く
まず2つの違いを超ざっくり
function 宣言
function updatePlayer() {
...
}
アロー関数
const updatePlayer = () => {
...
};
見た目以外に、性質が違います。
① 一番大きな違い:this
function の this
- 呼ばれ方で変わる
- オブジェクトやクラス向き
アロー関数の this
- 外側の this を固定で使う
- コールバック向き
👉 今回は this を使っていないので
致命的な差は出ていません。
② ホイスティング(これ重要)
function
updatePlayer(); // OK
function updatePlayer() {}
👉 先に呼べる
アロー関数
updatePlayer(); // ❌ エラー
const updatePlayer = () => {};
👉 定義より前では使えない
だからゲーム関数は function が向いてる
今回のように👇
function gameLoop() {}
function updatePlayer() {}
function drawPlayer() {}
function resetGame() {}
この書き方のメリット
- ファイルの下で
gameLoop()を呼べる - 上から順に読まなくても意味が通る
- 「ゲームの構造」が見やすい
👉 Canvasゲームと相性がいい
③ アロー関数が向いている場所
イベントリスナー
canvas.addEventListener("click", (e) => {
...
});
- その場で使う
- 再利用しない
- this を気にしない
👉 アロー関数のベスト用途
一時的な処理
playerimg.onload = () => {
playerImageLoaded++;
};
- 処理が短い
- 使い回さない
④ 今回のコードの使い分けはこう
function を使っていた所
- gameLoop
- update系
- draw系
- reset系
- 判定系
👉 「名前と役割を持つ処理」
アロー関数を使っていた所
- addEventListener
- onload
- その場のコールバック
👉 「その瞬間の反応」
⑤ 無理に統一しなくていい
たまに
「全部アロー関数で書くのがモダン!」
みたいな意見がありますが、
ゲームロジックでは全然そんなことないです。
読みやすさ・構造の方が大事。
Unityっぽく言うと
-
function→void UpdatePlayer() - アロー関数 →
() => { }な匿名処理(イベント)
👉 役割のある処理は名前を持たせる
超短い使い分けルール(覚えやすい)
-
何度も使う / ゲームの骨組み →
function -
一回きり / イベント反応 →
() => {} -
this を使う可能性がある →
function - 短く書きたいだけ → アロー関数
は~、ふーん?
最後に
jsとティラノスクリプト合わせて良い感じのゲームつくりた~い。頑張ります。ティラノスクリプトで積極的にjsを使っていくぞ。使わないと忘れるから…。