目視で見つからない表示の崩れを座標のテストで防ぐ
画面に並べた要素が少し重なっていたり、枠から数 px はみ出していたりする崩れは、画面を眺めるだけでは見つけにくいです。崩れていること自体は分かっても、どの数値がどれだけずれているかは、絵からは読み取れないです。
ブラウザで動かしている放置ゲームで、この種類の崩れを 2 つ出しました。どちらもスクリーンショットでの確認をすり抜け、遊んだ人からの指摘で分かったものです。対処は 2 つとも同じ形で、重ならない、はみ出さないという見た目の条件を、座標の判定として単体テストに書くことでした。
ゲームに限った話ではないです。図表の要素を並べる時、カレンダーに予定の帯を置く時、グラフの軸ラベルを配置する時にも、同じ形の条件が出てきます。
症状: 静止画では気づけなかった 2 つの崩れ
題材は、庭が舞台のゲームです。犬が奥から手前へ走ってきて、道沿いには灯り(ガーデンライト)が最大 12 本並びます。奥行きを出すため、奥にあるものほど小さく描いています。
街灯の形が欠けた
灯りを増やしていくと、灯りの箱が四角く描かれず、一部が欠けて見える、という指摘がありました。実際には欠けていたのではなく、奥の灯りが、手前にある別の灯りの支柱や箱に隠れていました。
灯りは 1 本が支柱と箱の 2 つの四角でできていて、どちらも数 px しかないです。これが別の灯りに 1 px 重なるだけで、形が崩れて見えます。

縮小された画面では、この欠けは灯りの模様としか見えないです。全体の雰囲気を見ているかぎり、崩れていると気づけないです。
犬が道からはみ出した
もう 1 つは、犬が左右に大きく動いて街灯に当たっている、という指摘でした。犬は奥から手前へ、左右にふらつきながら走ってきます。この動きは、灯りを足す前の何も置いていない庭では問題にならなかったです。道の脇に灯りが立ってはじめて、犬が道の外まで出ていたことが見えるようになりました。
こちらもスクリーンショットでは分からなかったです。犬の左右の位置は時間とともに揺れるので、はみ出しているのは周期の一部だけです。
原因: 見た目の条件が、どこにも書かれていなかった
2 件は別々の場所で起きていますが、構図は同じです。守りたかった条件は決まっていたのに、その条件はコードのどこにも書かれておらず、条件を満たすはずの数値だけが置かれていました。
| 守りたかった条件 | コードにあったもの | |
|---|---|---|
| 灯り | どの灯りも、他の灯りと重ならない | 灯りの大きさと、灯りを置く間隔。それぞれ別の式 |
| 犬 | 犬は道の幅から出ない | 左右の振れ幅。固定値 10 px で、道の幅とは連動しない |
灯りのほうは、大きさを決める式と、位置を決める式が別の関数に分かれていました。どちらの式も、単独で見れば妥当です。重ならないかどうかは、2 つを組み合わせた結果なので、どちらの式を読んでも出てこないです。
犬のほうは、道の幅が奥行きによって変わるのに、振れ幅だけが変わらなかったことが原因でした。
画面は横 96 px、縦 54 px の小さな座標で作っています。y は値が大きいほど手前で、y = 18 が地平線です。道は地平線から手前に向かって広がる台形で、その半分の幅を次の式で決めています。
/** 道の半幅(y が手前ほど広い) */
export function pathHalfWidth(y: number): number {
return 2 + Math.max(0, y - HORIZON) * 0.55;
}
犬の足元は、一番奥で y = 23、手前で y = 44 です。この式に入れると、道の半幅は奥で 4.75 px、手前で 16.3 px になります。一方の犬は、幅 20 px の絵を奥行きに応じて 0.3 倍から 1.0 倍に縮めて描くので、半分の幅は奥で 3 px、手前で 10 px です。
振れ幅が 10 px のままだと、一番奥では犬の右端が中央から 13 px の位置まで動きます。道の縁は中央から 4.75 px です。灯りは道の縁より 3 px 外に立つので、犬は道を越えて灯りの上まで届いていました。
手前で問題が出ていなかったのは、手前に着く直前だけ振れ幅を 0 に向かって絞る処理が入っていたためです。到着した時に犬が中央で止まって見えるようにするためのもので、道の幅とは関係がないです。結果として、絞りが効かない奥から中間にかけてだけ、はみ出しが残っていました。

図の左側が、はみ出していた時の状態です。手前では収まっているのに、奥ほど道が狭くなるぶんだけ外に出ます。手前の画面を見て問題なしと判断すると、奥の崩れがそのまま残ります。
2 件に共通していたのは、次の 3 点です。
- 条件は頭の中にあり、コードには条件を満たすつもりで置いた数値だけがあった
- その数値は複数の場所に分かれていて、片方を直した時にもう片方が追従しなかった
- 条件が満たされているかは、絵を見て判断するしかなかった
対処: 条件を座標の判定として書く
どちらも、条件をそのまま座標の判定として書きました。重なりは四角どうしの重なり判定、はみ出しは端の位置の比較です。
重ならないことを確かめる
灯り 1 本は、支柱と箱の 2 つの四角です。12 本ぶんの四角を並べて、全部の組み合わせで重なりを調べます。
it('どの灯りも、支柱と灯りが他の灯りと重ならない(隠れて欠けて見えない)', () => {
type R = [number, number, number, number];
const rects: R[][] = Array.from({ length: 12 }, (_, i) => {
const p = lightPos(i);
const { lw, lh, lx } = lampSize(p);
return [
[p.x, p.y - p.h, p.w, p.h],
[lx, p.y - p.h - lh, lw, lh],
];
});
const overlap = (a: R, b: R): boolean => a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3];
for (let i = 0; i < rects.length; i++)
for (let j = i + 1; j < rects.length; j++)
for (const a of rects[i]) for (const b of rects[j]) expect(overlap(a, b), `灯り ${i} と ${j}`).toBe(false);
});
判定しているのは、2 つの四角が x 方向でも y 方向でも範囲を共有しているかどうかだけです。12 本から 2 本を選ぶすべての組について、支柱と箱の 4 通りの組み合わせを見ます。失敗した時にどの 2 本かが分かるよう、番号をメッセージに入れています。
このテストは、数値を決める作業そのものにも使いました。灯りの大きさと間隔は、遠近を出すために奥ほど小さく・狭くしています。間隔は手前から奥へ 0.7 倍ずつ詰まるので、奥の段どうしは y が 2 px しか離れないです。その狭い隙間に入る大きさを、テストが通るまで試して決めました。
| 段 | 足元の y | 支柱 | 灯りの箱 |
|---|---|---|---|
| 手前 | 46 | 2 × 6 | 4 × 3 |
| 2 段目 | 38 | 2 × 5 | 4 × 3 |
| 3 段目 | 32 | 1 × 4 | 3 × 2 |
| 4 段目以降 | 28 / 25 / 23 | 1 × 3 | 1 × 1 |
手で数値を探す時に、絵を見て判断すると、1 回ごとに画面を確認することになります。合否をテストに任せると、試した値がそのまま通るか落ちるかで返ってきます。決まった数値は、そのときの条件を満たす組み合わせとして固定されます。
範囲から出ないことを確かめる
振れ幅は、固定値をやめて、その奥行きで動ける幅から出すようにしました。道の半幅から、その奥行きでの犬の半分の幅を引いた値です。
/** 道の外側(灯りなど)に当たらないよう、犬の端は道の縁から出さない(この値までは縁の外にはみ出してよい) */
const EDGE_MARGIN = 1.5;
/** その奥行きで、犬の中心が中央から左右へ動ける最大幅(犬の幅は奥ほど小さい)。 */
export function maxSway(z: number): number {
const halfWidth = (DOG_W / 2) * depthScale(z);
return Math.max(0, pathHalfWidth(feetY(z)) + EDGE_MARGIN - halfWidth);
}
縁から 1.5 px までは外に出てよいことにしています。犬の足が道の縁を少し踏んだほうが走っている感じが出るからで、灯りは縁より 3 px 外なので、この範囲なら灯りには届かないです。一番奥での振れ幅は 3.25 px になり、固定値だった 10 px から大きく縮みました。
テストは、位相と奥行きを総当たりして、犬の端が許される範囲に収まっているかを見ます。
it('走ってくる間、犬は道の幅の中に収まる(道の外の灯りに当たらない)', () => {
for (let sway = 0; sway < 6.3; sway += 0.1) {
for (let z = 0; z <= 1; z += 0.02) {
const half = 10 * depthScale(z);
const edge = pathHalfWidth(feetY(z)) + 1.5; // 灯りは道の縁より 3px 外
expect(Math.abs(dogX(z, sway) - 48) + half).toBeLessThanOrEqual(Math.max(edge, half) + 1e-9);
}
}
expect(maxSway(0.5)).toBeGreaterThan(0);
});
位相を 0.1 刻みで 1 周ぶん、奥行きを 0.02 刻みで奥から手前まで動かして、すべての組み合わせを調べています。左辺は犬の中心が中央から離れた距離に、その奥行きでの犬の半分の幅を足したもの、つまり犬の外側の端です。
総当たりにしたのは、どの位相で崩れるかが事前に分からないためです。はみ出しが起きるのは周期の一部だけで、代表的な値をいくつか選んで確かめる形だと、選んだ点がたまたま収まっている可能性が残ります。
奥行きと位相の 2 つを動かすので、確かめる組み合わせは数千通りになりますが、計算は座標の式を 1 回呼ぶだけなので一瞬で終わります。
最後の 1 行も必要です。振れ幅を 0 にすれば犬は中央から動かず、はみ出しの判定は常に通ります。中間の奥行きで振れ幅が 0 より大きいことを確かめて、条件を満たすだけの動かない実装になっていないかを見ています。
テストにできる条件と、できない条件
同じ時期に、灯りまわりでもう 1 つ指摘を受けました。灯りのまわりにほのかな光を足したところ、奥の支柱が手前の光でぼやけて見える、という症状です。
これは座標の重なりとしては拾えないです。光は半透明の四角なので、他のものと重なること自体は正しい状態です。問題は重なりの有無ではなく、どちらが先に描かれたかでした。灯りごとに光・支柱・箱の順で描いていたため、奥から手前へ進むうちに、手前の灯りの光が、すでに描き終えた奥の支柱の上に乗っていました。
対処は、判定を足すことではなく、描く順番を層に分けることでした。光を全部描いてから、その上に支柱と箱を描きます。
// 光を先に全部描き、その上に、奥の灯りから順に支柱と灯りを描く
const look = lightLook(levels.lights);
if (look.glow) for (let i = 0; i < look.count; i++) drawGlow(ctx, lightPos(i));
for (let i = look.count - 1; i >= 0; i--) drawLight(ctx, lightPos(i), look);
2 つの種類を並べると、違いがはっきりします。
| 条件の種類 | 例 | テストにできるか | 手当て |
|---|---|---|---|
| 位置の条件 | 重ならない、範囲から出ない | できる。座標の比較で判定できる | 総当たりの単体テスト |
| 順序や見え方の条件 | 後ろのものが手前の効果で濁らない | できない。位置の大小では決まらない | 描く順番を層として決めておく |
座標の条件かどうかが、分かれ目です。答えが位置の大小で決まるなら、判定として書けます。半透明を重ねた結果の色のように、描いた後のピクセルを見ないと分からないものは、別の手当てが要ります。
再発を防ぐ
今回の 2 件から、進め方を次のように変えました。
まず、条件を言葉にできた時点で、その場で判定に落とします。「重ならないように大きさを決めた」と書けるなら、それは重なり判定として書ける条件です。言葉にできたのにコードに現れていない条件は、後から読む人にも、数値を変える人にも見えないです。
次に、要素を新しく足す時は、既存の動きの前提を見直します。犬の振れ幅は、灯りを置くまで誰も困らなかったです。はみ出していても、はみ出した先に何も無かったためです。後から置いたものが、前に決めた動きの前提を壊す形になっていないかを、追加のたびに見ます。
そして、範囲や位置を決める値は、固定値ではなく、置かれた場所の寸法から導きます。振れ幅を道の幅と犬の大きさから計算するようにしたことで、道の形を変えても、犬の大きさを変えても、振れ幅が自動で追従します。
この 3 点は、画面に要素を並べるものなら同じように当てはまります。
| 場面 | 座標の判定として書ける条件 |
|---|---|
| 図やグラフの軸ラベル | 隣り合うラベルの文字領域が重ならない |
| カレンダーの予定の帯 | 同じ時間帯の帯が、列の幅の中に収まる |
| 画面に浮かせる通知やツールチップ | 画面の外に出ない。基準にした要素を覆わない |
どれも、要素の矩形と、置いてよい範囲さえ計算できれば判定できます。見た目を確かめるために画面を描く必要はないです。
まとめ
見た目の崩れのうち、重なりとはみ出しは、絵を見なくても座標の比較で確かめられます。2 件の崩れを出したあとに整理した要点は、次の 3 つです。
- 重ならない、範囲から出ないといった条件は、頭の中に置かず、座標の比較として書き出しておく
- 崩れる場所が事前に分からない条件は、位置や時間の刻みを総当たりして調べる
- 位置では決まらない条件は、重なりの判定ではなく、描く順番の決め方として形に残す