【HTML&CSSではじめるミニゲーム作成の基本】スプライトの接触判定をじっくり理解しよう
※ 当ページには【広告/PR】を含む場合があります。
2023/12/24

シューティングゲーム他、様々な平面上で表現される2次元ゲームにおいて、スプライト同士の接触をどのように決定するかは、これまでにさまざまなアルゴリズムが検討されてきた、とても深いテーマになっています。
HTMLで要素同士の接触をどのように設計するかは、Mozillaのmdn web docsにその実装指針が解説されています。
このブログ記事では、最も簡単なアルゴリズムである、「座標軸に沿った囲みボックス」と「円形衝突」について実例をもって考察していきます。
HTML/CSSでスプライトをキーボード入力操作できるプログラムを作成する
まずは接触判定などを一切考えないで、キーボードの上下左右キーからスプライトを動かすだけのHTMLプログラムを描いてみます。
まずは主要なHTML要素を以下の用に作成しておきます。
<body>
<section id="game-stage">
<div id="taco-screen">
<div id="taco"></div>
<div>
</section>
<section id="console-area">
<input type="text" id="game-status" name="message" value="">
</section>
</body>
HTMLゲームづくりの作成方針として、最初から要素ごとに
id
次にこのHTML要素へ以下のようなcssスタイルを仕込んでおきます。
body {
display: block;
margin: 0;
padding: 0;
}
#game-stage {
display: block;
position: relative;
width: 100%;
height: 280px;
padding: 0;
margin: 0 auto;
pointer-events: none;
/* ポイント① ... カスタムプロパティの利用 */
--x-pos: 20px;
--y-pos: 0px;
}
#taco-screen {
display: block;
position: absolute;
background: aqua;
padding: 0;
width: 80px;
height: 60px;
/* ポイント① ... カスタムプロパティの利用 */
top: var(--y-pos);
left: var(--x-pos);
}
#taco {
display: block;
width: 100%;
height: 100%;
background: transparent url('https://raw.githubusercontent.com/tacoskingdom/commonBlogMaterial/main/tacokin-p-school/blog109/character_juken_tako_okuto_pass.svg') no-repeat 0 0;
background-size: 80px 60px;
}
#console-area {
display: block;
position: absolute;
width: 100%;
height: 20px;
top: 280px;
padding: 0 0 0 0;
margin: 0 auto;
}
#game-status {
width: 100%;
font-size: 1.1rem;
background: #f5b560;
pointer-events: none;
border: navajowhite;
outline: none;
}
各DOM要素に細かいスタイルの指定を施している訳ですが、一つ一つ説明すると切りがないので、このコードの重要な点に絞ります。
もっとも重要な点として、
「キーボードの矢印キーからスプライトを動かす」
CSSのカスタムプロパティについては、別ブログでじっくり解説しましたので、そちらをご覧ください。
実際にスプライトを処理するのはjavascript側に頼る必要があります。
なので、jsスクリプト(
<script>タグ内
const isSupported = window.CSS && window.CSS.supports && window.CSS.supports('--a', 0);
if (isSupported) {
const stage = document.getElementById("game-stage");
const styles = getComputedStyle(stage);
const xpos = styles.getPropertyValue('--x-pos');
const ypos = styles.getPropertyValue('--y-pos');
const input = document.getElementById("game-status");
input.value = `矢印キーで動きます|[タコの位置]x=${xpos},y=${ypos}`;
document.body.addEventListener("keydown", (e) => {
const stage = document.getElementById("game-stage");
const styles = getComputedStyle(stage);
const xpos = styles.getPropertyValue('--x-pos');
const ypos = styles.getPropertyValue('--y-pos');
const input = document.getElementById("game-status");
input.value = `矢印キーで動きます|[タコの位置]x=${xpos},y=${ypos}`;
const key = e.keyCode;
if(key === 40){
stage.style.setProperty('--y-pos', `${parseInt(ypos.replace('px','')) + 4}px`);
}
if(key === 39){
stage.style.setProperty('--x-pos', `${parseInt(xpos.replace('px','')) + 4}px`);
}
if(key === 38){
stage.style.setProperty('--y-pos', `${parseInt(ypos.replace('px','')) - 4}px`);
}
if(key === 37){
stage.style.setProperty('--x-pos', `${parseInt(xpos.replace('px','')) - 4}px`);
}
e.preventDefault();
}, false);
}
else {
const input = document.getElementById("game-status");
input.value ='お使いのブラウザはカスタムプロパティ非対応です';
}
このスクリプトでも
なお、キーボード入力をJS側から処理するイベントは、色々なサイトで解説されている通りですので、ここでは割愛します。
キーボード入力イベントの実装の自体も重要ですが、HTMLブラウザゲーム作成に欠かせない、イベントの発火順序である
これについては、以下の記事を参考にしてください。
ここまでのプログラムを動かすと以下のようなHTMLアプリになるでしょう。
※以下のゲームを動かす場合、キーボード入力が受け付けないときは
さて、スプライトを動かすだけで、結構骨のある内容になっていますが、本題にも辿り着いていないので、もうしばらくお付き合いください。
座標軸に沿った囲みボックスで接触判定
接触判定でもっとも基本になるのが
囲みボックス
「囲みボックス」はスプライト画像をできるだけスッポリと収めるサイズに設定した四角の枠を指します。
565x401

自分の囲みボックスの内側に、他の要素の囲みボックスもしくは境界が入ったと判定される場合、それらの要素は「接触」したとみなします。
この記事では基本的な2つの接触タイプとして以下の実装を考えてみます。
1. 壁面(画面枠)との接触
2. 他のスプライトとの接触
他にもマップ上に設置された障害物だったり、イベント状態で移動を切り替えられるようにするなど、特殊なケースもありますが、応用的な課題はここでは取り上げません。
壁面(画面枠)との接触
スプライトが画面外へはみ出さないように制限を付けます。
具体的には、JSスクリプト側に以下のようなロジックを追加することになります。
if (
(スプライトのL < 左側の壁枠のx座標) || (スプライトのL+W > 右側の壁枠のx座標)
) {
//スプライトのx座標を変更しない
}
if (
(スプライトのT < 上側の壁枠のy座標) || (スプライトのT+H > 下側の壁枠のy座標)
) {
//スプライトのy座標を変更しない
}
これで、スプライトを移動しても画面外に見切れることはなくなります。
他のスプライトとの接触
考え方は壁面と同じで各軸の座標の大小関係を比較するのですが、他のスプライトにも
囲みボックス
599x458

この衝突領域が発生しているかどうかを調べるために、対象となる2つの囲みボックスの位置をx軸とy軸の順でチェックしていきます。
//x軸の位置関係からチェック
if ((自分のL > 相手のL+W) || (自分のL+W < 相手のL)) {
//衝突はしていないときの処理
...
}
else {
//衝突している可能性があるので、y軸の位置関係もチェック
if ((自分のT > 相手のT+H) || (自分のT+H < 相手のT)) {
//衝突はしていないときの処理
...
}
else {
//衝突しているときの処理
...
}
}
この時点では実装の分かりやすさを重視したため、コード的にはあまりきれいではないのですが、実践では判定する関数化を行って、汎用性のあるやり方に修正をする必要があります。
囲みボックスの接触判定を実装してみる
では上の内容を踏まえて、HTML/CSS/JSのパートを修正していきましょう。
<body>
<section id="game-stage">
<div id="taco-screen">
<div id="taco"></div>
</div>
<!-- 👇の要素を追加 -->
<div id="dagon-screen">
<div id="dagon"></div>
</div>
</section>
<section id="console-area">
<input type="text" id= "game-status" name="message" value="">
</section>
</body>
次にCSSスタイルの修正です。
body {
display: block;
margin: 0;
padding: 0;
}
#game-stage {
display: block;
position: relative;
width: 100%;
height: 280px;
padding: 0;
margin: 0 auto;
pointer-events: none;
--x-pos: 20px;
--y-pos: 0px;
/* 👇敵スプライトの座標 */
--x2-pos: 120px;
--y2-pos: 50px;
/* 👇接触判定時の背景色 */
--collision-state: aqua;
}
#taco-screen {
display: block;
position: absolute;
padding: 0;
width: 80px;
height: 60px;
top: var(--y-pos);
left: var(--x-pos);
/* 👇接触時に色が切り替わるように修正 */
background: var(--collision-state);
z-index: 1;
}
#taco {
display: block;
width: 100%;
height: 100%;
background: transparent url('https://raw.githubusercontent.com/tacoskingdom/commonBlogMaterial/main/tacokin-p-school/blog109/character_juken_tako_okuto_pass.svg') no-repeat 0 0;
background-size: 80px 60px;
}
/* 👇接触対象の敵スプライトのスタイルを追加 */
#dagon-screen {
display: block;
position: absolute;
padding: 0;
top: var(--y2-pos);
left: var(--x2-pos);
width: 80px;
height: 60px;
background: var(--collision-state);
z-index: 0;
}
#dagon {
display: block;
width: 100%;
height: 100%;
background: transparent url('https://raw.githubusercontent.com/tacoskingdom/commonBlogMaterial/main/tacokin-p-school/blog109/character_cthulhu_kuturufu.svg') no-repeat 0 0;
background-size: 80px 60px;
}
#console-area {
display: block;
position: absolute;
width: 100%;
height: 20px;
top: 280px;
padding: 0 0 0 0;
margin: 0 auto;
}
#game-status {
width: 100%;
font-size: 1.1rem;
background: #f5b560;
pointer-events: none;
border: navajowhite;
outline: none;
}
最後にJSスクリプトに衝突判定に実装します。
const isSupported = window.CSS && window.CSS.supports && window.CSS.supports('--a', 0);
if (isSupported) {
const stage = document.getElementById("game-stage");
const styles = getComputedStyle(stage);
const xpos = styles.getPropertyValue('--x-pos');
const ypos = styles.getPropertyValue('--y-pos');
const input = document.getElementById("game-status");
input.value = `矢印キーで動きます|[タコの位置]x=${xpos},y=${ypos}`;
document.body.addEventListener("keydown", (e) => {
const stage = document.getElementById("game-stage");
const styles = getComputedStyle(stage);
const stageW = Number(styles.width.replace('px',''));
const stageH = Number(styles.height.replace('px',''));
const xpos = styles.getPropertyValue('--x-pos');
const ypos = styles.getPropertyValue('--y-pos');
const x2pos = styles.getPropertyValue('--x2-pos');
const y2pos = styles.getPropertyValue('--y2-pos');
const input = document.getElementById("game-status");
input.value = `矢印キーで動きます|[タコの位置]x=${xpos},y=${ypos}|[敵の位置]x=${x2pos},y=${y2pos}`;
//👇2つのスプライトの座標と画像サイズ
const _x1 = parseInt(xpos.replace('px',''));
const _y1 = parseInt(ypos.replace('px',''));
const _x2 = parseInt(x2pos.replace('px',''));
const _y2 = parseInt(y2pos.replace('px',''));
const tacoW = 80;
const tacoH = 60;
const dagonW = 80;
const dagonH = 60;
const key = e.keyCode;
if(key === 40){
//下に移動(y座標を+4)
let _ny = _y1 + 4;
//👇画面下に接触した場合、座標を元に戻す
if (_ny + tacoH > stageH) _ny -= 4;
stage.style.setProperty('--y-pos', `${_ny}px`);
//👇敵スプライトとの接触判定
//x軸の位置関係からチェック
if (_x1 > _x2 + dagonW || _x1 + tacoW < _x2) {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
else {
//衝突している可能性があるので、y軸の位置関係もチェック
if (_ny > _y2+dagonH || _ny+tacoH < _y2) {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
else {
//衝突しているときの処理
stage.style.setProperty('--collision-state', 'red');
}
}
}
if(key === 39){
//右に移動(x座標を+4)
let _nx = _x1 + 4;
//👇画面右に接触した場合、座標を元に戻す
if (_nx + tacoW > stageW) _nx -= 4;
stage.style.setProperty('--x-pos', `${_nx}px`);
//👇敵スプライトとの接触判定
//x軸の位置関係からチェック
if (_nx > _x2 + dagonW || _nx + tacoW < _x2) {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
else {
//衝突している可能性があるので、y軸の位置関係もチェック
if (_y1 > _y2+dagonH || _y1+tacoH < _y2) {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
else {
//衝突しているときの処理
stage.style.setProperty('--collision-state', 'red');
}
}
}
if(key === 38){
//上に移動(y座標を-4)
let _ny = _y1 - 4;
//👇画面上に接触した場合、座標を元に戻す
if (_ny < 0) _ny += 4;
stage.style.setProperty('--y-pos', `${_ny}px`);
//👇敵スプライトとの接触判定
//x軸の位置関係からチェック
if (_x1 > _x2 + dagonW || _x1 + tacoW < _x2) {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
else {
//衝突している可能性があるので、y軸の位置関係もチェック
if (_ny > _y2+dagonH || _ny+tacoH < _y2) {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
else {
//衝突しているときの処理
stage.style.setProperty('--collision-state', 'red');
}
}
}
if(key === 37){
//左に移動(x座標を-4)
let _nx = _x1 - 4;
//👇画面左に接触した場合、座標を元に戻す
if (_nx < 0) _nx += 4;
stage.style.setProperty('--x-pos', `${_nx}px`);
//👇敵スプライトとの接触判定
//x軸の位置関係からチェック
if (_nx > _x2 + dagonW || _nx + tacoW < _x2) {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
else {
//衝突している可能性があるので、y軸の位置関係もチェック
if (_y1 > _y2+dagonH || _y1+tacoH < _y2) {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
else {
//衝突しているときの処理
stage.style.setProperty('--collision-state', 'red');
}
}
}
e.preventDefault();
}, false);
}
else {
const input = document.getElementById("game-status");
input.value ='お使いのブラウザはカスタムプロパティ非対応です';
}
以上を組み上げると、以下のようなHTMLプログラムで動くと思います。
※以下のゲームを動かす場合、キーボード入力が受け付けないときは
円形領域で接触判定
先程の囲みボックスが理解できたら、
円形領域とはその名の通りで、画像を囲う円によって他のスプライトや壁面との接触判定を行う方式です。
569x497

判定の起点となるのは、
先程の2つの接触判定ロジックも少し調整が必要で、画面枠との判定は以下のようにします。
if (
(スプライト中心のx座標 - R < 左側の壁枠のx座標) || (スプライト中心のx座標 + R > 右側の壁枠のx座標)
) {
//スプライトのx座標を変更しない
}
if (
(スプライト中心のy座標 - R < 上側の壁枠のy座標) || (スプライト中心のy座標 + R > 下側の壁枠のy座標)
) {
//スプライトのy座標を変更しない
}
他のスプライトとの接触判定は更にシンプルになり、
if (自分のR + 相手のR > D) {
//衝突はしていないときの処理
...
}
else {
//衝突しているときの処理
...
}
というようにロジックを修正します。
円形領域の接触判定を実装してみる
では上の内容を踏まえて、CSS/JSのパートを修正していきましょう。 (※HTMLパートはそのまま利用)
まずCSSは円形領域の表示にちょこっと変えただけです。
/* ...中略 */
#taco-screen {
display: block;
position: absolute;
padding: 0;
background: var(--collision-state);
z-index: 1;
width: 80px;
top: var(--y-pos);
left: var(--x-pos);
/* 👇円形領域になるように調整 */
height: 80px;
border-radius: 50%;
}
#taco {
display: block;
width: 100%;
height: 100%;
background: transparent url('https://raw.githubusercontent.com/tacoskingdom/commonBlogMaterial/main/tacokin-p-school/blog109/character_juken_tako_okuto_pass.svg') no-repeat 0 0;
/* 👇円形領域になるように調整 */
background-size: 80px 80px;
}
#dagon-screen {
display: block;
position: absolute;
padding: 0;
top: var(--y2-pos);
left: var(--x2-pos);
width: 80px;
background: var(--collision-state);
z-index: 0;
/* 👇円形領域になるように調整 */
height: 80px;
border-radius: 50%;
}
#dagon {
display: block;
width: 100%;
height: 100%;
background: transparent url('https://raw.githubusercontent.com/tacoskingdom/commonBlogMaterial/main/tacokin-p-school/blog109/character_cthulhu_kuturufu.svg') no-repeat 0 0;
/* 👇円形領域になるように調整 */
background-size: 80px 80px;
}
/* ...以下略 */
次に、JSスクリプトはロジックを入れ替えしているのですが、割と使いまわしも多いです。
実装は以下のようにしました。
const isSupported = window.CSS && window.CSS.supports && window.CSS.supports('--a', 0);
if (isSupported) {
const stage = document.getElementById("game-stage");
const styles = getComputedStyle(stage);
const xpos = styles.getPropertyValue('--x-pos');
const ypos = styles.getPropertyValue('--y-pos');
const input = document.getElementById("game-status");
input.value = `矢印キーで動きます|[タコの位置]x=${xpos},y=${ypos}`;
document.body.addEventListener("keydown", (e) => {
const stage = document.getElementById("game-stage");
const styles = getComputedStyle(stage);
const stageW = Number(styles.width.replace('px',''));
const stageH = Number(styles.height.replace('px',''));
const xpos = styles.getPropertyValue('--x-pos');
const ypos = styles.getPropertyValue('--y-pos');
const x2pos = styles.getPropertyValue('--x2-pos');
const y2pos = styles.getPropertyValue('--y2-pos');
const input = document.getElementById("game-status");
input.value = `矢印キーで動きます|[タコの位置]x=${xpos},y=${ypos}|[敵の位置]x=${x2pos},y=${y2pos}`;
//👇2つのスプライトの座標と画像サイズと円形領域の半径
const _x1 = parseInt(xpos.replace('px',''));
const _y1 = parseInt(ypos.replace('px',''));
const _x2 = parseInt(x2pos.replace('px',''));
const _y2 = parseInt(y2pos.replace('px',''));
const tacoW = 80;
const tacoH = 80;
const tacoR = 40;
const dagonW = 80;
const dagonH = 80;
const dagonR = 40;
//2つのスプライトの中心位置
let _cx1 = _x1 + tacoW/2;
let _cy1 = _y1 + tacoH/2;
let _cx2 = _x2 + dagonW/2;
let _cy2 = _y2 + dagonH/2;
const key = e.keyCode;
if(key === 40){
//下に移動(y座標を+4)
let _ny = _y1 + 4;
//👇画面下に接触した場合、座標を元に戻す
_cy1 = _ny + tacoH/2;
if (_cy1 + tacoR > stageH) {
_ny -= 4;
_cy1 = _ny + tacoH/2;
}
stage.style.setProperty('--y-pos', `${_ny}px`);
}
if(key === 39){
//右に移動(x座標を+4)
let _nx = _x1 + 4;
//👇画面右に接触した場合、座標を元に戻す
_cx1 = _nx + tacoW/2;
if (_cx1 + tacoR > stageW) {
_nx -= 4;
_cx1 = _nx + tacoW/2;
}
stage.style.setProperty('--x-pos', `${_nx}px`);
}
if(key === 38){
//上に移動(y座標を-4)
let _ny = _y1 - 4;
//👇画面上に接触した場合、座標を元に戻す
_cy1 = _ny + tacoH/2;
if (_cy1 - tacoR < 0) {
_ny += 4;
_cy1 = _ny + tacoH/2;
}
stage.style.setProperty('--y-pos', `${_ny}px`);
}
if(key === 37){
//左に移動(x座標を-4)
let _nx = _x1 - 4;
//👇画面左に接触した場合、座標を元に戻す
_cx1 = _nx + tacoW/2;
if (_cx1 - tacoR < 0) {
_nx += 4;
_cx1 = _nx + tacoW/2;
}
}
//👇敵スプライトとの接触判定(中心距離の位置関係からチェック)
const d = Math.sqrt((_cx1 - _cx2)*(_cx1 - _cx2) + (_cy1 - _cy2)*(_cy1 - _cy2));
if (tacoR + dagonR > d) {
//衝突しているときの処理
stage.style.setProperty('--collision-state', 'red');
}
else {
//衝突はしていないときの処理
stage.style.setProperty('--collision-state', 'aqua');
}
e.preventDefault();
}, false);
}
else {
const input = document.getElementById("game-status");
input.value ='お使いのブラウザはカスタムプロパティ非対応です';
}
これで再度、HTML/CSS/JSを試すと、以下のように「円形領域」の衝突判定が可能となるでしょう。
※以下のゲームを動かす場合、キーボード入力が受け付けないときは
まとめ
以上、今回はブラウザからでも即時動くHTML&CSSミニゲームづくりに役立つ、「スプライトの接触判定」について実際のサンプルコードとともに解説していきました。
ただし、今回は高々2つのスプライトの接触を判定するだけだったので、とても簡単そうに見えましたが、「接触判定」の眞の難しさは上記で説明したような接触判定のそのもののアルゴリズムよりも、
こちらのアルゴリズムについても機会があればまた今後じっくり解説していきましょう。