JS:スマホの縦でも横でもキャンバスが収まるようにする

JavaScript

JavaScriptのキャンバス APIを使ってグラフィックや画像を描画するとき、スマホを(ポートレート)にしても(ランドスケープ)にしても縦横比を保ったままキャンバス全体を表示できるようにする処理について解説します。

イメージ

やりたい事

例として横320ピクセル×縦400ピクセルのキャンバスを準備します。

<canvas id="canvas" width="320" height="400"></canvas>

これにキャンバス APIをつかって文字や画像を描画したものをスマホを縦(ポートレート状態)で見たときのイメージがこちらです。

背景の青色部分がキャンバス範囲と考えてください。
キャンバスの縦横比は保ったまま端末サイズに合わせてキャンバスをめいっぱい広げた感じです。

スマホを横(ランドスケープ状態)にすると、以下のように縦横比を変えることなくキャンバス全体が表示されるようにしたい、というのが今回の主旨です。

横にしても縦横比を保ったまま表示されている

キャンバスに白い塗りつぶし円がありますが、タッチしたまま指を動かすと追随するようになっています。PCで実行する場合は、F12キーで開発者モードにし、デバイスをスマホやタブレットモードに変更してから試してください。

ソースコード

解説

HTMLとCSS

index.html 部分の解説です。

今回キャンバスに文字を描画しているのですが、環境によって文字フォントが存在しなかったり、フォントサイズがまちまちでずれてしまうため、Googleフォントを利用して文字フォントをRobotoに統一しています。(ちょっとした工夫ですが大事だと思います)

<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@500&display=swap" rel="stylesheet">
<!-- /Google Fonts -->

キャンバスのタグは幅と高さを固定してid付きで宣言しています。

<canvas id="canvas" width="320" height="400"></canvas>

キャンバス幅1に対して、高さが1.25の割合の比率のキャンバスとなります。

JavaScript側でこの1.25の比率をグローバル変数canvasHeightRatioに格納して表示する際に利用しています。

CSSは外側余白・内側余白のリセットに加えて、canvasの行の高さ(line-height)もリセットしています。

*{
	margin: 0;
	padding: 0;
	line-height: 0;	/* ブラウザでキャンバスの下に隙間ができるのを防ぐ */
}

line-height: 0; を設定しないとキャンバスタグの下に微妙な余白ができてしまいます。
試しにline-height項目のみコメントアウトしてみてください。ブラウザに縦方向のスクロールバーが表示されてしまいます。

JavaScript

main.js 部分の解説です。

関数一覧

大きくわけて5つの関数があり、それぞれの役割を示しておきます。

関数名処理内容
touchMoveListenerキャンバスでタッチして指を動かしたときの処理
drawCanvasキャンバスへの描画処理
getSize端末サイズ変更時の処理を行う
例)
・ブラウザのウインドウサイズを変更したとき
・スマホを縦または横にしたとき
・その他、起動時にも一度実行される

主な処理:
1.端末サイズを取得する(幅と高さ)
2.端末サイズからキャンバスの表示サイズを再設定する
GameMainフレーム毎の処理(1秒間に約60回ほど実行される)
※キャンバスへの描画を一定時間ごとに描画するため
loadイベント
window.addEventListener(“load”, ()=>{~});
起動時に実行される処理(ブラウザに読み込まれた時)

一番重要な関数は、getSizeです。

今回の処理内容(スマホの縦でも横でもキャンバスが収まるようにする)を達成する上で必須となります。

端末サイズが変更(スマホを縦や横にした際)されるごとに、getSizeを呼び出してキャンバスの縦横比を変えずに描画するようにしているからです。

グローバル変数で重要な変数

重要な変数は、canvasHeightRatioresolutionRatioです。

let canvas = null;				// キャンバス(描画領域)
let g = null;							// 描画対象
let pos = {x:0, y:0};				// キャンバス上のXY座標
let canvasHeightRatio;		// キャンバス幅に対する高さの割合
let resolutionRatio = 0;		// 元キャンバスに対する表示サイズの大きさ割合

// 画像オブジェクト(画像ファイルをキャンバスで利用するため)
let img;

canvasHeightRatioは、キャンバスの幅に対する高さ割合を示し、今回幅320ピクセル×高さ400ピクセルなので、

320 / 400 = 1.25

となります。(幅1に対して高さが1.25ということです)

キャンバスの縦横比は変えることがないためこの値は起動時のloadイベント時に設定され固定値となります。(後述するgetSize関数で利用しています)

resolutionRatioは、元キャンバスに対する表示サイズの大きさ割合を表しています。(この値もgetSize関数が呼び出された時に再設定されます)

例えば元のキャンバスサイズが320×400ピクセルでresolutionRatioが2.0なら、キャンバス画面上では640×800ピクセルで表示されていることになります。

変数resolutionRatioの利用場面ですが、今回はタッチして動かしたときの白い円の描画に利用しています。
キャンバス上でタッチしたときのxy座標はあくまで元サイズでのxy座標となってしまうため、そのままタッチ位置を使って描画するとキャンバスへの描画がずれてしまうからです。

具体的には、グローバル変数のpos.xpos.yにタッチ位置を設定する際にresolutionRatioを使っています。

const touchMoveListener = (e) => {
	// 端末のデフォルト動作をキャンセル
	e.preventDefault();

	// タッチ開始情報を取得
	const touches = e.touches;

	// ページ上のオフセット位置取得
	const rect = e.target.getBoundingClientRect();

	// キャンバス上のXY座標を取得
	pos.x = Math.floor(touches[0].pageX/resolutionRatio - rect.left);
	pos.y = Math.floor(touches[0].pageY/resolutionRatio - rect.top);
};

起動時の処理(loadイベント)

/*
 * 起動時の処理
 */
window.addEventListener("load", ()=>{
	// キャンバス取得
	canvas = document.querySelector("#canvas");
	g = canvas.getContext("2d");	// コンテキスト:描画対象

	// キャンバスの幅に対する高さの割合を取得
	canvasHeightRatio = canvas.height / canvas.width;
	console.log("キャンバスの幅に対する高さの割合:" + canvasHeightRatio);
	
	// キャンバス文字描画設定
	g.font = "normal 24px Roboto";	// フォントサイズ・フォント種類 設定
	g.textBaseline = "top";			// テキスト描画時のベースライン

	// タッチイベント設定
	canvas.addEventListener("touchmove", touchMoveListener, false);

	// 画像ファイルを読み込む
	img = new Image();
	img.src = "https://dianxnao.com/public/assets/cat.png";

	// 画像読み込みが完了してから開始
	img.addEventListener("load", ()=>{
		console.log("画像読み込み完了!");
		getSize();	// 端末サイズチェック
		GameMain();	// ゲームループ開始
	}, false);
});

前述した変数canvasHeightRatioキャンバスの幅に対する高さの割合を設定しています。

canvasHeightRatio = canvas.height / canvas.width;

これは固定値となり変わることはありません。

文字列描画を環境により変化させないためにフォントはGoogleフォントのRobotoを使っています。
また、フォントの描画開始位置を文字の左上を起点とした方が使いやすいため、ベースラインをtopに設定しています。

// キャンバス文字描画設定
g.font = "normal 24px Roboto";	// フォントサイズ・フォント種類 設定
g.textBaseline = "top";		// テキスト描画時のベースライン

今回キャンバスにクロネコのドット絵を描画しています。
本サイトにサーバにアップロードした画像ファイルcat.pngを利用しているためURL指定となっています。

// 画像ファイルを読み込む
img = new Image();
img.src = "https://dianxnao.com/public/assets/cat.png";

画像ファイルは読み込みに一定の時間が掛かります。
きちんと画像ファイルが読み込まれてから、端末サイズ取得(getSize関数)、処理全体(GameMain関数)へと処理を渡しています。

// 画像読み込みが完了してから開始

img.addEventListener("load", ()=>{
	console.log("画像読み込み完了!");
	getSize();	// 端末サイズチェック
	GameMain();	// ゲームループ開始
}, false);

端末サイズチェック(getSize関数)

getSize関数は端末サイズが変更されたときに実行する必要があるためresizeイベントで呼び出されるように設定しておきます。

// 端末サイズが変更されたときのイベント処理
window.addEventListener("resize", getSize, false);

getSize関数

/*
 * キャンバスのサイズを端末サイズに合わせて変更
 */
const getSize = ()=>{
	// 端末ウインドウ幅に対するウインドウ高さの割合を取得
	const windowHeightRatio = window.innerHeight / window.innerWidth;
	
	// 端末のウインドウサイズからキャンバスサイズを再計算する
	let width;
	let height;

	if (windowHeightRatio > canvasHeightRatio) {	// 縦長状態(ポートレイト)
		width = window.innerWidth;
		height = window.innerWidth * (canvas.height / canvas.width);
	} else {										// 横長状態(ランドスケープ)
		width = window.innerHeight * (canvas.width / canvas.height);
		height = window.innerHeight;
	}

	// 現在の端末でキャンバスを表示する際のサイズを設定
	canvas.style.width  = `${width}px`;
	canvas.style.height = `${height}px`;
	console.log(`キャンバス表示サイズ w:h = ${width}px:${height}px`);

	// 元キャンバス(canvas.height)に対する表示サイズ(height)の大きさ割合を計算(正しいタッチ座標の取得に利用)
	resolutionRatio = height / canvas.height;
	console.log("resolutionRatio = " + resolutionRatio);
}

スマホが縦か横か(あるいはPCが縦長か横長か)を判別するため、現在の端末ウインドウ幅に対する高さ割合windowHeightRatioに取得しておきます。

// 端末ウインドウ幅に対するウインドウ高さの割合を取得

const windowHeightRatio = window.innerHeight / window.innerWidth;

これを起動時に取得したcanvasHeightRatioと比較して縦長か横長かを判定し端末サイズにおけるキャンバスサイズwidthheightに設定しています。

// 端末のウインドウサイズからキャンバスサイズを再計算する
let width;
let height;

if (windowHeightRatio > canvasHeightRatio) {	// 縦長状態(ポートレイト)
	width = window.innerWidth;
	height = window.innerWidth * (canvas.height / canvas.width);
} else {					// 横長状態(ランドスケープ)
	width = window.innerHeight * (canvas.width / canvas.height);
	height = window.innerHeight;
}

最終的にcanvas.style.widthcanvas.style.heightにこの値を設定して、スマホの縦でも横でもキャンバスが収まるようにする処理を実現しています。

// 現在の端末でキャンバスを表示する際のサイズを設定
canvas.style.width  = `${width}px`;
canvas.style.height = `${height}px`;

canvas.width は canvas 自体の描画領域の幅、その canvas をブラウザで表示する幅canvas.style.width で指定します。 – 参考:ドットインストール より

フレーム毎の処理(GameMain関数)

/*
 * ゲームのメイン処理
 */
const GameMain = () =>{
	drawCanvas();
	// フレーム再描画
	requestAnimationFrame(GameMain);
}

名称がGameMainとしてあるのはよくゲームなどでこうしたフレーム毎の処理をゲームループなどと呼ぶためで大した意味はありません。

処理としてはdrawCanvas関数をフレーム毎に(1秒間に約60回)呼び出しているだけの再帰処理です。

キャンバスへの描画(drawCanvas関数)

/*
 * キャンバスに描画する
 */
const drawCanvas = () =>{
	// 背景塗りつぶし
	g.fillStyle = "royalblue";
	g.fillRect(0, 0, canvas.width, canvas.height);

	// テキスト描画
	g.fillStyle = "snow";
	const titleText = "Crime and Punishment";
	const textObj = g.measureText(titleText);	// キャンバス文字列の情報を得る
	g.fillText(titleText, canvas.width/2-textObj.width/2, canvas.height/4);	// 画面中央にくるように配置

	// ●を描画
	g.fillStyle = "snow";
	g.beginPath();
	g.arc(pos.x, pos.y, 20, 0, Math.PI*2, false);
	g.fill();

	// 画像ファイルを描画
	g.drawImage(img, canvas.width/2 - img.width/2, canvas.height/2 - img.height/2);
}

キャンバスに文字列を中央揃えで表示しています。(g.fillTextの第2引数canvas.width/2-textObj.width/2の部分)

measureTextメソッドを使うと文字列長に応じたキャンバス上でのサイズ情報などが取得できます。

// テキスト描画

g.fillStyle = "snow";
const titleText = "Crime and Punishment";
const textObj = g.measureText(titleText);	// キャンバス文字列の情報を得る
g.fillText(titleText, canvas.width/2-textObj.width/2, canvas.height/4);	// 画面中央にくるように配置

タッチして指を動かした時、白い塗りつぶし円を描画しますが、タッチ座標は、後述する関数touchMoveListenerで設定されるpos.xpos.yを利用しています。

g.arc(pos.x, pos.y, 20, 0, Math.PI*2, false);

画像ファイルは常にキャンバス中央に描画するように表示位置を画像サイズとキャンバスサイズから計算して描画しています。

// 画像ファイルを描画
g.drawImage(img, canvas.width/2 - img.width/2, canvas.height/2 - img.height/2);

タッチして指を動かしている時の処理(touchMoveListener関数)

/*
 * タッチして指を動かしている時の処理
 */
const touchMoveListener = (e) => {
	// 端末のデフォルト動作をキャンセル
	e.preventDefault();

	// タッチ開始情報を取得
	const touches = e.touches;

	// ページ上のオフセット位置取得
	const rect = e.target.getBoundingClientRect();

	// キャンバス上のXY座標を取得
	pos.x = Math.floor(touches[0].pageX/resolutionRatio - rect.left);
	pos.y = Math.floor(touches[0].pageY/resolutionRatio - rect.top);
};

スマホでタッチイベントを利用する場合に大事な点は、スマホのデフォルトでのタッチ動作をキャンセルことです。これは関数の先頭で必須です。

// 端末のデフォルト動作をキャンセル
e.preventDefault();

タッチイベントは基本的に配列に格納されます。(複数タッチに対応しているため)

// タッチ開始情報を取得
const touches = e.touches;

ブラウザの左上からキャンバスの左上の距離(オフセット)がある場合があるため、getBoundingClientRectメソッドで距離を取得します。(今回はCSSで* {margin: 0; padding: 0;} としているため意味はないかもしれませんが)

// ページ上のオフセット位置取得
const rect = e.target.getBoundingClientRect();

touches[0]を使っているのは、touchesは配列のため、最初にタッチした指の位置をタッチ座標とするためです。

// キャンバス上のXY座標を取得
pos.x = Math.floor(touches[0].pageX/resolutionRatio - rect.left);
pos.y = Math.floor(touches[0].pageY/resolutionRatio - rect.top);

以上、長々と説明してしまいましたが、この辺で失礼します<(_ _)>

コメント

タイトルとURLをコピーしました