「拡大表示」を作り込む:画面サイズの変化に追従する
遊びをつくる執筆にClaudeを使用しています。

ブラウザゲームのウィンドウを広げたとき、画面の中のキャラクターがぼやけたり、タップした位置と反応する位置がずれたり、ゲームの進行がリセットされたりすることがあります。これらは別々の不具合に見えますが、原因をたどると「ゲームの世界の大きさ」と「画面に描く大きさ」を同じものとして扱っている点に行き着くことが多くあります。 この記事の問いは、「サイズが変わっても、見た目・入力・進行の三つが崩れない描画は、どう設計すれば作れるか」です。答えは、世界の座標を一つに固定し、画面サイズの変化は描画の最後の一段だけで吸収する、という分け方です。この記事では、単独のHTMLファイルで動く最小の実装を番号付きの手順で組み、そのあとで読者が自分の環境で確かめる方法を示します。 アソバースの公式作品13本は、言語の変更やプレイ画面の拡大をしても進行を保つ作りだと案内されています。作品ごとの内部実装は公開情報にないため、ここでは「拡大しても進行が残る」という性質を、自作で再現するための設計として扱います。
この記事の目次
先に決める:三つの座標を分ける
拡大表示でつまずく原因の多くは、座標の種類を混ぜることです。最初に三つを区別します。
- 論理座標、何の単位か:ゲームの世界の単位(この記事では幅320×高さ180)、変わるタイミング:変わらない
- CSSピクセル、何の単位か:ページ上で見える大きさ、変わるタイミング:ウィンドウ、ブラウザのズームで変わる
- 物理ピクセル、何の単位か:canvasが実際に持つ画素数、変わるタイミング:CSSの大きさと画面の密度(devicePixelRatio)で決まる
キャラクターの位置、当たり判定、スコアなど、ゲームの状態はすべて論理座標で持ちます。画面サイズが変わっても、これらの値は一切書き換えません。
変えてよいのは、論理座標を物理ピクセルへ写す倍率だけです。描画のたびに「論理座標から物理ピクセルへの変換」を一回かけ、入力を受けるときは逆向きに「画面上の位置から論理座標」へ戻します。この往復の変換を一か所に閉じ込めておけば、サイズ変更に強い描画の土台になります。
手順1:最小構成を作る
次のファイルを index.html として保存し、ブラウザで開きます。外部ライブラリもサーバーも使いません。
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;height:100%;background:#123;overflow:hidden}
#stage{width:100%;height:100%;display:grid;place-items:center}
canvas{display:block;touch-action:none}
</style>
<div id="stage"><canvas id="c"></canvas></div>
<script>
const W=320,H=180;
const stage=document.getElementById('stage');
const cv=document.getElementById('c'),ctx=cv.getContext('2d');
const s={x:160,y:90,vx:60,vy:40};
function fit(){
const b=stage.getBoundingClientRect();
const k=Math.min(b.width/W,b.height/H);
const d=window.devicePixelRatio||1;
cv.style.width=W*k+'px';
cv.style.height=H*k+'px';
cv.width=Math.max(1,Math.round(W*k*d));
cv.height=Math.max(1,Math.round(H*k*d));
}
new ResizeObserver(fit).observe(stage);
addEventListener('resize',fit);
function toLogical(e){
const r=cv.getBoundingClientRect();
return {x:(e.clientX-r.left)/r.width*W,y:(e.clientY-r.top)/r.height*H};
}
cv.addEventListener('pointerdown',e=>{
const p=toLogical(e);s.x=p.x;s.y=p.y;
});
let last=performance.now();
function frame(t){
const dt=Math.min(0.05,(t-last)/1000);last=t;
s.x+=s.vx*dt;s.y+=s.vy*dt;
if(s.x<8){s.x=8;s.vx=Math.abs(s.vx)}
if(s.x>W-8){s.x=W-8;s.vx=-Math.abs(s.vx)}
if(s.y<8){s.y=8;s.vy=Math.abs(s.vy)}
if(s.y>H-8){s.y=H-8;s.vy=-Math.abs(s.vy)}
ctx.setTransform(cv.width/W,0,0,cv.height/H,0,0);
ctx.fillStyle='#246';ctx.fillRect(0,0,W,H);
ctx.strokeStyle='#fff';ctx.strokeRect(0.5,0.5,W-1,H-1);
ctx.fillStyle='#fc6';
ctx.beginPath();ctx.arc(s.x,s.y,8,0,7);ctx.fill();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>開くと、黄色い玉が枠の中を跳ね返り続けます。枠の中をクリックまたはタップすると、玉がその位置へ移ります。
手順2:コードの要点を一つずつ押さえる
この小さなコードに、拡大表示の設計がほぼ入っています。順に確認します。
- 表示領域の測定:fit の中で、ステージ(canvasを入れる箱)の大きさを測ります。ウィンドウの大きさを直接使わず箱を測るのは、ゲームが他のページに埋め込まれても同じ書き方で通用するためです。
- 倍率kの決め方:Math.min(b.width/W, b.height/H) で、横と縦のうち厳しい方に合わせます。こうすると縦横比(16:9)が保たれ、余った方向には余白ができます。
- CSSの大きさと画素数を分ける:見た目の大きさ(style.width)は W*k です。画素数(canvas.width)はそれに devicePixelRatio をかけます。高密度の画面でも輪郭がぼやけにくくなります。
- 描画の変換は毎フレームの先頭で一回:setTransform(cv.width/W, …) で、以降の描画命令は論理座標のまま書けます。arc(s.x, s.y, 8, …) に画面倍率が一切出てこない点に注目してください。
- 入力は逆変換:toLogical は、canvasの現在の表示矩形から論理座標を求めます。クリック位置の計算に使う基準が「今の表示矩形」なので、拡大しても縮小してもずれません。
- 変更の検知は二重:ResizeObserver は箱の大きさの変化を拾います。resize イベントは、ブラウザのズーム操作など密度が変わる場面の保険です。
なお、canvasの width や height を書き換えると描画内容が消えます。このコードでは毎フレーム全面を描き直すので問題ありませんが、描き直さない作りにする場合は、サイズ変更の直後に必ず再描画する必要があります。
手順3:自分の環境で確かめる
動いたら、次の観点を一つずつ確認します。結果は読者の環境で決まるため、ここでは記録の取り方と判断基準だけを示します。
確認する操作
- ブラウザのウィンドウを横長・縦長に動かし、玉が枠から欠けたり、枠が歪んだりしないかを見る。
- ブラウザのズーム(Ctrl/Cmd と +/−)を数段階変え、枠の輪郭の鮮明さを見る。
- 玉が動いている最中にウィンドウを大きく変え、玉の軌道が途切れたり、位置が飛んだりしないかを見る。
- 枠の四隅と中央をクリックし、玉がクリックした位置に現れるかを見る。サイズを変えるたびに繰り返す。
- 密度の異なる画面(ノートPC本体とVD外部ディスプレイなど)がある場合は、ウィンドウを移動して見え方を比べる。スマホやタブレットの縦横回転も同様に確認する。
記録の取り方と読み方
表を作り、操作ごとに「合格/ずれた/ぼやけた/飛んだ」のいずれかを書き込みます。判断の目安は次のとおりです。
- クリック位置がずれる:toLogical が古い矩形を使っていないか、または canvas に枠線・余白(padding や border)が付いていないかを疑います。
- 輪郭がぼやける:devicePixelRatio を掛け忘れているか、CSSの大きさと画素数が合っていない可能性があります。
- 玉の位置が飛ぶ:状態が画面倍率で書き換えられていないかを確認します。このコードでは書き換えないので、改造後に起きたなら改造箇所が原因です。
- ズーム後だけ崩れる:resize の監視が外れていないかを確認します。
各操作は最低でも三回、小さい幅から大きい幅へ、大きい幅から小さい幅へと、順序を入れ替えて行ってください。変更の順序で結果が変わる場合は、fit が前回の値に依存している証拠です。
起こりやすい失敗と直し方
実装で多い失敗を挙げます。
- ゲームの状態を画面ピクセルで持つ:位置や速度をピクセルで持つと、拡大のたびに換算が必要になり、進行がずれます。状態は論理座標だけにします。
- 整数に丸めずに非整数の大きさを使う:画素数は Math.round で整数にします。小数のまま代入するとブラウザ側で切り捨てられ、端に細い隙間が出ることがあります。
- dtを制限しない:ウィンドウ操作中はフレームが止まりやすく、戻った瞬間に大きな時間差が入って玉が飛びます。Math.min(0.05, …) のような上限を置きます。
- 細い線がにじむ:1論理単位の線は、倍率が小数だとにじみます。上のコードで枠を 0.5 ずらして描いているのは、そのための工夫です。
- サイズ変更のたびに状態を初期化する:初期化の処理を fit の中に書かないでください。fit は「描く器を作り直す」だけの関数にします。
一段進める:余白・文字・進行の保持
最小構成が通ったら、実際のゲームに近づける要素を足します。
余白の使い方。 縦横比を保つと必ず余白が出ます。単色で塗るだけでなく、背景を余白の方まで描く、操作ボタンを余白側に置く、といった使い方ができます。ただし、ボタンの位置を論理座標で決めるのか、画面の端に寄せるのかは先に決めておきます。論理座標に置けば入力の逆変換を使い回せます。
文字の扱い。 canvasに描く文字も論理座標で大きさを指定すれば、倍率に合わせて拡縮します。小さい画面でも読める最小の大きさは、実機で確かめてください。文字を重要な情報の伝達に使う場合、HTMLの要素で重ねる方法もありますが、その場合は位置と大きさの追従を別途計算する必要があります。
進行の保持。 状態が論理座標だけで持たれていれば、拡大表示や言語切り替えで描画を作り直しても、進行はそのまま残ります。これが、冒頭で触れた「進行を保つ」ための考え方です。一方で、ページを閉じたあとも残したい記録は別の話です。アソバースでは、アップロード作品内の localStorage/sessionStorage は一時メモリー用の互換処理とされています。残したい進行がある場合は、SDK経由のクラウドセーブ(作品ごとにJSONで128KiBまで)の利用を検討してください。詳細は現在の案内で確認します。
動きを抑える設定。 拡大表示とは別ですが、OSの「動きを抑える」設定に合わせて演出を減らす対応も、仕上げ段階で入れておくとよい項目です。prefers-reduced-motion で判定できます。
なお、アソバースではゲームがsandbox付きのiframeで実行されます。親ページの機能に頼る処理(たとえば全画面化の許可など)は、環境によって使えない場合があります。そのため、この記事では「渡された表示領域の変化に追従する」ことだけを土台にしています。全画面ボタンを足す場合は、失敗しても通常の表示で動き続ける作りにしてください。
次の行動
まず手順1のファイルを保存し、確認する操作の五つを、三回ずつ試して記録してください。ずれやぼやけが見つかったら、「起こりやすい失敗」の項目と照らして原因を絞り込みます。自作のゲームがあれば、状態を論理座標で持っているかを点検し、描画の変換が一か所に集まっているかを確かめてください。
参考資料
- MDN Web Docs「ResizeObserver」
- MDN Web Docs「Window.devicePixelRatio」
- MDN Web Docs「CanvasRenderingContext2D.setTransform()」
- MDN Web Docs「prefers-reduced-motion」

