スタイルシート インベーダーっぽいゲーム の説明
戻る


JavaScript を使わず,スタイルシートだけでスペース インベーダーみたいなゲームを作ってみます.
スタイルシートだけでスペース インベーダーと同じものを作るのはどう頑張っても無理なので,あくまでもスペース インベーダー風なものです.
全くのお遊びです.これが何か実用の役に立つようなことは多分無いでしょう.スタイルシートは本来,動作を記述するものではありませんので,かなり力ずくで作っています.
画面
(これはキャプチャ画像です.これで遊ぶことはできません.)
使い方と仕組みについて説明します.


使い方

「スタート」をクリックするとゲームを開始します.
インベーダーが画面上部から攻めてきます.それを画面下部にある砲で撃って倒します.
マウスを左右に動かすと砲が左右に動きます.マウスの左ボタンで弾を発射します.
インベーダーが撃ってくる弾が砲に当たると砲が破壊されます.
インベーダーをすべて倒せばクリアです.クリアするか,または砲が破壊されるかインベーダーが最下段まで降りてきたらゲーム終了です.
ゲーム終了後に「リセット」をクリックすると画面が初期の状態に戻ります.その後「スタート」をクリックすると,またゲームを開始します.

砲を移動しながら弾を発射することはできません.細かく言うと,マウス ボタンを押下したままマウスを横に動かしてからボタンを放したときは弾は発射されません.
また,弾が同時に二発以上発射されている状態にすることはできません.つまり,弾がインベーダーに命中するか画面上部で消えるまで,次の弾を発射することはできません.
弾を発射するとき,マウス ボタンを押下している間は画面が停止しますが,その点はつっこまないでください.


カーソル位置の取得とボタン押下の判定

スタイルシートの機能では,直接マウス カーソルの位置を取得することはできません.どの HTML 要素の上にカーソルがあるかは :hover 擬似クラスで判定できますが,カーソルの座標は判りません.
このプログラム(?)では,幅 2px の要素を横に並べて敷き詰め,:hover 状態になった要素を調べることで,カーソルの X 座標を 2px 単位で取得します.
位置取得用/ボタン押下判定用要素 1
弾を発射するマウス ボタンの押下はラジオ ボタンのチェックで判定します.各インベーダーに対応するラジオ ボタン(40 個)と,あと二つのラジオ ボタンを使います.
インベーダーに対応するボタンはインベーダーに命中する弾を発射する処理に使います.残りの二つのボタンはインベーダーに命中しない弾を発射する処理に使います.
ラジオ ボタンは非表示にしておき,各ラジオ ボタンに対してラベル(LABEL タグ)を関連付けます.
ラベルの幅は 2px にします.位置取得用の要素が :hover 状態になったら,その上にラベルを重ねます.
位置取得用/ボタン押下判定用要素 2
その状態でマウス ボタンを押下すると,ラジオ ボタンがチェックされ,ラジオ ボタンの :checked 擬似クラスの条件が発生します.それによってマウス ボタンの押下を判定します.
後述のように,ラベルは命中するインベーダーに対応するものが前面になるように Z オーダーをセットします.

ひとつのラジオ ボタンについてマウス ボタンの押下を判定するだけでよければ,直接ラベルを敷き詰めて,ラベル要素でカーソル位置の取得とボタン押下の判定を行うこともできますが,複数のラジオ ボタンについてマウス ボタンの押下を判定する必要があるので,上記のような処理を行っています.

なお,これだけだとカーソル位置の取得とボタン押下の判定の両方を同時に行うことができません.
ラベルが上に重なると,位置取得用の要素は :hover 状態ではなくなり,その時点で :hover 条件のセレクタは無効になります.ラジオ ボタンが :checked 状態になったときには位置取得用の要素は :hover 状態ではなくなっているので,位置を取得できません.
そのため,後述の「情報の保存」の方法で :hover 状態になった要素の位置を保存しています.


ゲーム終了時の処理

インベーダーとインベーダーが発射する弾,砲が発射する弾などをアニメーションで動かします.その他にも,いろいろなカスタム プロパティの値をアニメーションで変化させています.
それらのアニメーションは,特定の HTML 要素が :hover 状態のときに動かすようにしています.それは,ゲームが終了したらその要素が :hover 状態でなくなるようにして,動きが止まるようにしているためです.
画面全体を覆う要素(以下「マスク要素」という)を用意して,ゲームが終了したらその要素の Z オーダーを変えて最前面にします.マスク要素を最前面にすると他の要素は(下記の例外を除き) :hover 状態でなくなるのでアニメーションが停止します.
また,カーソル位置取得用の要素やラベルが背面になるので,砲の移動や弾の発射もできなくなります.

マスク要素に関してひとつ注意することがあります.
マスク要素でマウス イベントが発生すると,そこから祖先要素にイベントが伝搬します.そのため,マスク要素でマウスがホバーすると祖先要素が :hover 状態になります.もし,マスク要素の祖先要素の :hover 状態でアニメーションを動かすようにしたとすると,マスク要素を最前面にしてもアニメーションは停止しません.
ですので,マスク要素の祖先ではない要素の :hover 状態でアニメーションを動かすようにする必要があります.


インベーダーの移動

三つのカスタム プロパティをアニメーションで変化させてインベーダーを移動させます.
--inv-x-base
--inv-off-y1
--inv-off-y2
--inv-x-base は横位置の基準で,0 〜 179 の範囲で 1 ずつ増加させ,それを繰り返します.
その値によってインベーダーの横位置が次のようになります.
0 〜 80左端から値のピクセル数の分右へ移動する
81 〜 89左右に移動しない
90 〜 170右端から値 - 90 のピクセル数の分左へ移動する
171 〜 179左右に移動しない
--inv-off-y1--inv-off-y2 は縦位置を決める要素です.
--inv-off-y1 は 0 〜 240 の範囲で,縦方向から横方向への移動に変わるときに 15 増加させます.
--inv-off-y2 は左右に移動中は 0 で,右端/左端では 0 〜 14 の範囲で 1 ずつ増加させ,それを繰り返します.
--inv-off-y1--inv-off-y2 の値の和のピクセル数がインベーダーの縦位置になります.
インベーダーの縦位置
この位置に各インベーダーのオフセットを加えたものがそれぞれのインベーダーの位置になります.
この三つのプロパティの値の組み合わせで,インベーダーが横移動と縦移動を繰り返すようにしています.

消えていないインベーダーのうち一番下にあるものを調べ,その縦位置が砲の位置になったらゲーム終了の処理を行います.


インベーダーの弾の処理

インベーダーが発射する弾の横位置は固定です.インベーダーが左右に一往復する間に,インベーダーの縦の列毎に一回ずつ決まった位置から発射します.
インベーダーの移動のアニメーションと同期して,該当の位置に来たときに弾を発射するアニメーションを行います.

砲の位置とインベーダーの弾の位置によって,弾が砲に当たったか判定します.弾が砲に当たったらゲーム終了の処理を行います.


インベーダーの弾の縦方向の発射位置

インベーダーの弾は縦に並んだ列の一番下のインベーダーが撃ってきます.弾の縦方向の発射位置を決めるために,消えていないインベーダーのうち一番下にいるものを調べます.その位置から弾が下に向かって飛んでくるアニメーションをする訳ですが,その処理がちょっと面倒です.
こちらの弾がインベーダーに当たった時点でそのインベーダーが消えて,そのインベーダーが一番下だった場合は弾の発射位置が上に変わります.しかし,そのインベーダーが発射した弾があったときは,その弾については元の発射位置を維持しなければなりません.そうでないと,飛んでくる途中で弾の位置が上に戻ってしまうことになります.

元の発射位置を保持しておくこと自体は,後述の「情報の保存」の方法でできます.しかし,インベーダーに弾が当たったときには何もセレクタが変化しないので,その時点で発射位置を保存することができません.
そこで,こちらが弾を発射したときにラジオ ボタンの :checked 状態で発射位置を保存しておきます.そして,こちらが弾を発射したときにインベーダーが弾を発射していたときや,弾がインベーダーに当たる前にインベーダーが弾を発射するときは,保存しておいた発射位置を使うような処理をしています.
インベーダーの弾は一定の時間間隔で発射されるので,インベーダーが弾を発射するまで,または発射してからの時間は判ります.その時間とこちらが発射した弾がインベーダーに当たるまでの時間によって,保存しておいた発射位置を使うかどうかを選択します.
当たる前にインベーダーが弾を発射する場合
インベーダーの弾の発射位置 1

当たるまでにインベーダーが弾を発射しない場合
インベーダーの弾の発射位置 2
この辺の処理は transition でプロパティの変化を一定時間遅らせる方法を使えると,もう少し簡単にできると思うのですが,それはできません.
上述のように,特定の HTML 要素が :hover 状態のときにアニメーションを動かすようにしているので,マウス カーソルがブラウザの画面外に出るとアニメーションが停止します.カーソルがブラウザの画面外に出ている間ゲームが中断しても,それは別に差し支え無いと思いますが,transition は途中で一時停止させることができないため,もし transition を使うと,カーソルがブラウザの画面外に出たときに他のアニメーションとの整合性がとれなくなってしまいます.


命中の判定

砲の位置と弾の速度,インベーダーの位置と移動方向・速度,インベーダーまでの距離によって,各インベーダーに命中するかどうかを判定します.
命中の判定
各インベーダーに対応するラジオ ボタンに対応するラベルは,命中するインベーダーに対応するものが前面になるように Z オーダーをセットします.
縦に並ぶインベーダーについては二つ以上が命中する条件を満たす場合があります.その場合,Z オーダーは同じになりますが,下にいるインベーダーに対応するラベルが HTML では後になるように並べているので,下にいるインベーダーのラベルの方が前面になります.
命中するインベーダーが無いときは,インベーダーに命中しない弾を発射する処理用のラジオ ボタンに対応するラベルが前面になるように Z オーダーをセットします.
画面をクリックすると前面にあるラベルに対応するラジオ ボタンがチェックされるので,そのボタンに対応して弾の発射の処理を行います.
インベーダーに命中する場合
ラベルの重ね順 1

インベーダーに命中しない場合
ラベルの重ね順 2


命中する弾の処理

命中するインベーダーに対応するラジオ ボタンが :checked 状態になったら,弾の移動とインベーダーの消滅のアニメーションを行います.
弾の縦方向の位置を表すカスタム プロパティを変化させ,それにしたがって弾を移動させます.位置がインベーダーの位置になったら,インベーダーの表示を消滅の画像に変えます.
弾はインベーダーの位置になったら表示位置を画面外にして隠しますが,位置を表すカスタム プロパティはそのまま変化させ続けます.その値が一定の値に達したら,インベーダーの表示(消滅の画像)を Z オーダーを変えて隠します.
命中する弾の処理
弾はインベーダーを表示するための要素の ::before 疑似要素で表示しています.別の要素を使ってもよいのですが,::before 疑似要素を使うとその分 HTML タグが少なくなるので,そのようにしています.

インベーダーがすべて消えたらゲーム終了の処理を行います.


命中しない弾の処理

命中しない弾の処理にラジオ ボタンを二つ使います.
命中しない弾は何度も発射されるので,クリックを繰り返し検出するために,ラジオ ボタンを二つ使って交互にチェックされるようにします.
ラジオ ボタンのチェック状態によってラベルの Z オーダーを変えて,一方のラジオ ボタンがチェックされているとき,もう一方のラジオ ボタンのラベルの方が前面になるようにします.ラベルをクリックする度にラベルの重なり順が入れ替わって,ラジオ ボタンが交互にチェックされるようになります.
命中しない弾の発射のクリックの検出
ラジオ ボタンが :checked 状態になったら,弾の移動と爆発のアニメーションを行います.


得点の表示

得点の表示には CSS カウンタを使っています.CSS カウンタの一般的な使い方は,counter-reset/counter-set で値を設定して counter-increment で値を増減させるというものだと思いますが,このプログラムでは counter-reset で値を設定しているだけです.
消えたインベーダーの数を counter-reset でカウンタに設定して,counter() を使って表示しています.つまり,CSS カウンタは単に数値を文字列に変換する機能として使っています.


情報の保存

カーソル位置の取得やインベーダーの弾の発射位置を維持する処理などで,セレクタで取得した情報をセレクタの条件が無効になった後も保存しておく必要があります.そのために transition の機能を流用します.
transition では,プロパティの設定値が変更されてから実際に値が変わり始めるまでのディレイを設定できます.情報を保存したいカスタム プロパティに,このようにディレイを設定します.
transition:--a 0s 10000s;
そうすると,セレクタが条件に該当しなくなっても,ディレイで指定した時間が経過するまでは設定した値が保持されます.ディレイに充分大きな値を指定すれば,事実上設定した値が保存されることになります.
ただし,値を設定するときにディレイが設定されていると,その時間が経過するまで値が変わらなくなってしまいますので,値を設定するときには,transition を無効にするかディレイをゼロにします.

カスタム プロパティに transition を適用するために,後述のように @ルールの @property でプロパティをアニメーション可能に設定しています.


カスタム プロパティの定義

このプログラムでは,多くのカスタム プロパティを @ルールの @property で定義しています.
@property の定義は,たとえばこのように書きます.
@property --a {
  syntax:'<integer>';
  inherits:true;
  initial-value:0;
}
上述したように,情報を保存するために transition を使っている他,本来の用途でカスタム プロパティにアニメーション(transition も含む)を使っているところもあります.カスタム プロパティに計算値によるアニメーションや transition を使うためには,プロパティのデータ型を定義する必要があります.
@propertysyntax<integer> を指定することで,そのプロパティを計算値によるアニメーション可能にしています.

また,計算結果の小数を整数に丸めるためにも @property を使っています.整数のプロパティを定義して,計算結果をそのプロパティに設定することで,整数への丸めを行っています.


条件分岐

スタイルシートでは,プログラミング言語のように if 文などで条件を判定して処理を分けるようなことができません.プロパティの設定はすべて式として書かなければなりません.また,直接数値の大小を比較するような機能もありません.
そのため,条件によって値を変えるところは,すべての条件の場合の値を計算しておいてから,そのうちのどれかが選ばれるような式を書くような形になります.C 言語の条件演算子(? :)や Visual Basic の If 演算子のようなものがあれば便利なのですが,今のところそのような機能は無いようです.

たとえば --x に,ある条件を満たす場合は --n1,満たさない場合は --n2 の値を設定するという場合,条件を満たす場合に 1,満たさない場合に 0 となるようなプロパティ --b を計算しておいて
--x:calc(var(--b) * var(--n1) + (1 - var(--b)) * var(--n2));
と書きます.
--b はプログラミング言語で言うブール変数に相当します.
--b の計算の仕方は,たとえば --v が整数を表すプロパティである場合,その値が 42 に等しいかどうかを表す --b はこのように計算できます.(小数の場合はもう少し面倒です.)
--w:calc(var(--v) - 42);
--b:calc(1 - min(var(--w) * var(--w), 1));
こんな風にも書けます.
--w:clamp(-1, var(--v) - 42, 1);
--b:calc(1 - var(--w) * var(--w));
abs() が実装されていれば,もう少し簡単に書けます.
--b:calc(1 - min(abs(var(--v) - 42), 1));
--b1--b2 を条件の真偽を表すプロパティ(1/0)とすると,論理演算は次のように計算できます.
論理積(AND)
  var(--b1) * var(--b2)
論理和(OR)
  min(var(--b1) + var(--b2), 1)
否定(NOT)
  1 - var(--b1)
このような考え方で,条件分岐的な処理を力業で書いています.


上記の説明では細部を略しているところがあります.また,この他にもいろいろと小細工をしていますが,説明しきれませんので興味のある方はコードを読んでください.

このおもちゃは Opera 80 以上用に作っていますが,Google Chrome 107 で動くことが確認できています.Google Chrome の他のバージョンでの動作は未確認です.


戻る