私はZEN大学生のsamoyed130-zenです。主に情報系分野を履修しています。
Hanabee が「ZEN Study 動くWebページコンテスト2025 夏」で優秀賞をいただいたので、制作過程と工夫点を3本に分けてまとめています(Day2)。
-
Day2:フロントエンド(Canvas/演出/UX)(この記事)
Day1では、ルールと全体設計(状態・HUD・連鎖・コンボ・フィーバー)を決めました。
Day2はそれを 「ブラウザでちゃんと遊べる」 まで持っていくフロントエンドの工夫点をまとめます。
0. 使った技術(最小構成)
- HTML:画面の枠(Canvas、HUD、オーバーレイ)
- CSS:レイアウト中心(ほぼTailwind)
- JavaScript:ゲームの中身(ロジック + Canvas描画)
このプロジェクトは index.html と scripts/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で touchmove を preventDefault() して、
ゲーム中の操作がページに伝播しないようにしています。
(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/スマホを統一
イベントを click と touch で分けると分岐が増えます。
そこで 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へ。