0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hanabee 制作記(Day2)フロントエンド:Canvasゲームを“遊べる”形にする

Last updated at Posted at 2025-12-16

私はZEN大学生のsamoyed130-zenです。主に情報系分野を履修しています。

Hanabee が「ZEN Study 動くWebページコンテスト2025 夏」で優秀賞をいただいたので、制作過程と工夫点を3本に分けてまとめています(Day2)。

Day1では、ルールと全体設計(状態・HUD・連鎖・コンボ・フィーバー)を決めました。
Day2はそれを 「ブラウザでちゃんと遊べる」 まで持っていくフロントエンドの工夫点をまとめます。


0. 使った技術(最小構成)

  • HTML:画面の枠(Canvas、HUD、オーバーレイ)
  • CSS:レイアウト中心(ほぼTailwind)
  • JavaScript:ゲームの中身(ロジック + Canvas描画)

このプロジェクトは index.htmlscripts/main.js が主役です。


1. UIを「Canvasだけ」にしない(役割分担が重要)

Canvasゲームでつまずきやすいのは、
文字表示・入力・ボタンまで全部Canvasで作り始める ことです。

Hanabeeでは、

  • Canvas:花火/ターゲット描画、演出
  • DOM(HTML要素):スコアや時間、名前入力、ボタン

に分担しました。

index.html の構造はこの発想です。

<div id="gameWrap" class="relative ...">
	<canvas id="stage" class="w-full bg-slate-900"></canvas>

	<!-- HUD(DOM) -->
	<span id="uiTime">060</span>
	<span id="uiScore">00000000</span>
	<span id="uiCombo">00</span>
	<div id="comboGaugeFill" style="width:0%"></div>

	<!-- Overlay(DOM) -->
	<div id="overlay" class="hidden ...">
		<input id="playerName" ... />
		<button id="ovStart">Restart</button>
	</div>
</div>

Canvasは描画が得意ですが、入力欄のIMEやアクセシビリティ対応は苦手。
DOMで作った方が結果的に「早く、堅く」仕上がります。


2. 画面サイズ問題:16:9を維持して、表示崩れを防ぐ

Webゲームで最初に当たる壁が「画面サイズがバラバラ」問題です。
特にスマホは、アドレスバーの出入りや回転でサイズが激しく変わります。

Hanabeeは、

  • 見た目の比率は 16:9 固定
  • 高さが足りない場合は“横幅を縮めて”収める
  • visualViewport が取れるならそれを優先

という方針で resize() を組みました。

function resize() {
	const stageWrap = canvas.parentElement;
	const parentW = stageWrap ? stageWrap.clientWidth : document.documentElement.clientWidth;

	const vh = (window.visualViewport && window.visualViewport.height)
		? window.visualViewport.height
		: window.innerHeight;
	const vw = (window.visualViewport && window.visualViewport.width)
		? window.visualViewport.width
		: window.innerWidth;

	const maxH = Math.floor(vh * 0.9);

	const ASPECT_W = 16, ASPECT_H = 9;
	let cssW = Math.max(320, parentW);
	let cssH = Math.round(cssW * ASPECT_H / ASPECT_W);

	if (cssH > maxH) {
		cssH = maxH;
		cssW = Math.round(cssH * ASPECT_W / ASPECT_H);
	}
	if (cssW > vw) {
		cssW = Math.floor(vw);
		cssH = Math.round(cssW * ASPECT_H / ASPECT_W);
	}

	canvas.style.width = cssW + 'px';
	canvas.style.height = cssH + 'px';

	const rect = canvas.getBoundingClientRect();
	canvas.width  = Math.floor(rect.width  * dpr);
	canvas.height = Math.floor(rect.height * dpr);
	ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
addEventListener('resize', resize, { passive: true });
resize();

ポイント:

  • CSSの width/height と、Canvasの canvas.width/height は別物
  • Canvasの内部解像度(canvas.width)をちゃんと上げないとボケる

3. くっきり表示(Retina対策):devicePixelRatio を使う

Macやスマホは画面が高密度です。
そこで devicePixelRatio を使って内部解像度を上げています。

const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));

Math.min(2, ...) と上限を付けているのは、
高すぎる解像度で重くなるのを防ぐためです。


4. スマホ対策:スクロール・ズームを“絶対に”発生させない

スマホSafariは、タップやスワイプで
ページスクロールやズームが発生しがちです。

まずCSSで、Canvasをジェスチャー対象から外します。

<style>
	html, body { overscroll-behavior: none; }
	#stage { touch-action: none; -ms-touch-action: none; }
</style>

さらにJSで touchmovepreventDefault() して、
ゲーム中の操作がページに伝播しないようにしています。

(function preventMobileScrollOnCanvas(){
	const opts = { passive: false };
	canvas.addEventListener('touchmove', (e) => { e.preventDefault(); }, opts);

	// iOSのダブルタップズーム抑制(ベストエフォート)
	let lastTap = 0;
	canvas.addEventListener('click', (e) => {
		const now = Date.now();
		if (now - lastTap < 350) e.preventDefault();
		lastTap = now;
	}, true);
})();

補足:

  • passive: false にしないと preventDefault() が効かない場合がある
  • iOSは癖が強いので、CSSとJSの両方で固めると安定します

5. 縦持ち(portrait)を“そもそも動かさない”

スマホ縦持ちは、

  • 16:9 のCanvasが収まらない
  • 要素が display:none になり Canvasサイズが0になりがち

など、事故が起きやすいです。

Hanabeeは「縦持ちではゲームを動かさない」方針にしました。

(1) CSSで縦持ちはゲームを隠す

@media (orientation: portrait) {
	#gameWrap { display: none; }
	#rotateNotice { display: grid; }
}
@media (orientation: landscape) {
	#gameWrap { display: block; }
	#rotateNotice { display: none; }
}

(2) JSでも初期化を止める(保険)

const isPortrait = () => {
	if (window.matchMedia) return window.matchMedia('(orientation: portrait)').matches;
	return (window.innerHeight || 0) >= (window.innerWidth || 0);
};

if (isPortrait()) {
	const reloadIfLandscape = () => { if (!isPortrait()) location.reload(); };
	window.addEventListener('orientationchange', reloadIfLandscape, { passive: true });
	window.addEventListener('resize', reloadIfLandscape, { passive: true });
	return;
}

意図:
「縦持ち対応をがんばる」より、壊れない状態を先に作ると完成まで行けます。


6. 演出の工夫:FX用キャンバスで残像を作る

花火っぽさは「残像」と「加算合成(明るく重なる)」が大事です。
でも、ターゲット(丸)に残像が付くと見づらい。

そこで、

  • メインCanvas:ターゲットをくっきり描く
  • FX用Canvas(オフスクリーン):粒だけ残像付きで描く
  • 最後に合成する

という二層構成にしました。

// FX用キャンバス
const fx = document.createElement('canvas');
const fxctx = fx.getContext('2d');

function fxFade(trailAlpha=0.14){
	fxctx.save();
	fxctx.globalCompositeOperation = 'destination-out';
	fxctx.fillStyle = `rgba(0,0,0,${trailAlpha})`;
	fxctx.fillRect(0,0,fx.width/dpr,fx.height/dpr);
	fxctx.restore();
}

function drawParticlesOnFx(){
	fxctx.save();
	fxctx.globalCompositeOperation = 'lighter';
	for(const p of particles){
		fxctx.beginPath();
		fxctx.arc(p.x,p.y,p.size,0,Math.PI*2);
		fxctx.fillStyle=`hsla(${p.hue} ${p.sat}% ${p.light}% / ${p.alpha})`;
		fxctx.fill();
	}
	fxctx.restore();
}

メインループでは、ターゲットを描いた後にFXを drawImage で載せます。

drawTargets();
if (particles.length>0) fxFade(0.14);
stepParticles(dt);
drawParticlesOnFx();
ctx.drawImage(fx, 0, 0, fx.width/dpr, fx.height/dpr);

「派手だけど見やすい」の両立ができました。


7. 入力の工夫:pointerdown でPC/スマホを統一

イベントを clicktouch で分けると分岐が増えます。
そこで pointerdown を使い、1本化しました。

canvas.addEventListener('pointerdown', (e)=>{
	const {x,y} = canvasPos(e);
	if (playing) tryHitAt(x,y);
	else addFirework(x,y); // タイトル中も遊べる
});

タイトル中も花火が出るのは、
「触った瞬間に楽しい」導線を作るための小さな工夫です。


8. HUDの工夫:ゼロ埋めでレイアウトを崩さない

小手先のテクニックですが、スコアやタイムはゼロ埋めしてレイアウト崩れを防止しています。
レガシーなアーケードゲームっぽくなるメリットもあります。

const zpad = (n, width) => String(Math.max(0, Math.floor(n))).padStart(width, '0');

if (soloUI.time)  soloUI.time.textContent  = zpad(Math.ceil(gameTime), 3);
if (soloUI.score) soloUI.score.textContent = zpad(score, 8);
if (soloUI.combo) soloUI.combo.textContent = zpad(Math.min(combo, MAX_COMBO), 2);

9. オーバーレイ(タイトル/遊び方/結果)を状態で切り替える

画面遷移を増やすより、
1つのオーバーレイを状態で切り替える方がシンプルです。

Hanabeeではボタンの挙動を overlayAction に入れて、
タイトル→遊び方→開始 を繋げています。

let overlayAction = null;

function showTitle(){
	soloUI.ovTitle.textContent = GAME_NAME;
	soloUI.ovStart.textContent = 'Next';
	overlayAction = () => { savePlayerName(...); showHowTo(); };
	toggleOverlay(true);
}

function showHowTo(){
	soloUI.ovTitle.textContent = '遊び方 / 操作';
	soloUI.ovStart.textContent = 'START';
	overlayAction = () => { startGame(); };
	toggleOverlay(true);
}

soloUI.ovStart.addEventListener('click', ()=>{
	if (typeof overlayAction === 'function') overlayAction();
});

意図:
「画面が増える=変数とバグが増える」ので、
同じUIを使い回すのが安全です。


10. フロントエンドのまとめ(再現性のある工夫)

  • CanvasとDOMを分ける(入力はDOM)
  • 16:9 固定 + visualViewport 対応で崩れにくく
  • devicePixelRatio でくっきり
  • スマホのスクロール/ズームを封じる
  • portraitは動かさない(壊れない方を優先)
  • FX用Canvasで「派手だけど見やすい」を実現

次回(Day3)

Day3ではバックエンドとして、
Google Apps Script(GAS)+スプレッドシートで
オンラインランキングを作った方法を解説します。


シリーズとリンク

この投稿は「Hanabee制作記」シリーズのDay2です。続きはDay3へ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?