🎮

HTMLとCSSだけで簡単なゲームを作ってみた

2024/12/04に公開

この記事はCSS Advent Calendar 2024の3日目の記事です。

先日(といっても9月ですが)、社内でのLT会とSendai Frontend Meetup #13で「HTMLとCSSだけで簡単なゲームを作ってみた」というネタでLTをしてきました。

LTではざっくりした説明になったので、技術的な詳細を記事にしておきます。
LTで使ったスライドはこちら。

完成品

動作しているものを見てもらうのが早いので、粗いですが完成品のgifアニメーションです。

ゲームを遊んでいる動画

Webページとして公開しているので良かったら遊んでみてください。

上からボールが流れてきてタッチすればクリアというシンプルなゲームです。
難易度が5段階あって、レベルが上がるごとにボールが小さくなり速くなります。

ソースコードはGitHubに上がっていますので良かったら見てください。
綺麗に書こうとしていないので色々と雑な部分はあります。

https://github.com/KanDai/touch-the-ball

実装の詳細

このゲームの実装で重要なのは、CSSの :has() 擬似クラスです。
:has() は2024年2月からすべてのモダンブラウザで使えるようになりました。

https://developer.mozilla.org/ja/docs/Web/CSS/:has

CSSの可能性を広げる革新的な機能だという評判でしたが、そこまで使えていなかったので実際に使ってどういうことができるかを試してみようと思いました。

使い方を調べていくとゲームのようなものを作るサンプルがあり、面白そうだったので自分でも何か作ってみようと考え作成しました。

タイトル画面 / ゲーム画面への遷移

タイトル画面は全画面で、タイトルとスタートボタンがあります。

このスタートボタンがHTMLの <input type="checkbox"> になっています。

タイトル画面のスタートボタンがチェックボックスになっている

<label class="button">
    <input type="checkbox" name="start" /> Start
</label>

全体が .wrapper で囲まれていて、以下のように書くことでスタートボタンを押す(チェックする)と :has([name='start']:checked) の対象になり、タイトル画面が非表示になってゲーム画面が表示されます。

.wrapper:has([name='start']:checked) .titleView {
    display: none;
}

同じようにスタートボタンがチェックされると、レベル1のボールが表示されてゲームが始まります。

タイトル画面とゲーム画面の説明

.wrapper:has([name='start']:checked) .ball[value='lv1'] {
    display: block;
}

下に流れるボールをタッチする

ボールは <input type="radio"> で作られていて、シンプルなCSSアニメーションの繰り返しで上から下に流れるようになっています。

<input type="radio" name="ball" value="lv1" class="ball" />
.ball {
    position: absolute;
    animation: slide-down var(--ball-animation-duration) linear
        infinite var(--ball-animation-delay);
}

@keyframes slide-down {
    from {
        top: calc(0% - var(--ball-size));
    }
    to {
        top: 100%;
    }
}

タイトル画面のスタートボタンと同じように、チェックされると has(.ball[value='lv1']:checked) の対象になってクリア画面が表示されます。

クリア画面は3秒で自動的に画面外にスライドされて次のレベルのゲームが始まります。

radioボタンで実装されたボール

<div class="clear modal" data-lv="1">
    ...
</div>
.modal {
    display: none;
}

.wrapper:has(.ball[value='lv1']:checked) .clear[data-lv='1'] {
    display: grid;
    animation: slide-out 0.4s ease-in-out forwards 3s;
}

ボールのレベル設定

ボールはレベルに合わせて要素を5つ用意しています。

元々はひとつの要素で実装していたんですが、チェックされているボールのレベルを :has() で使えるようにしたくてこうなりました。

<input type="radio" name="ball" value="lv1" class="ball" />
<input type="radio" name="ball" value="lv2" class="ball" />
<input type="radio" name="ball" value="lv3" class="ball" />
<input type="radio" name="ball" value="lv4" class="ball" />
<input type="radio" name="ball" value="lv5" class="ball" />

以下のようにすることでチェックされているボールの次のレベルのボールだけが表示されます。

.ball {
    display: none;
}

.ball:checked + .ball {
    display: block;
}

難易度は5段階あって徐々にボールが速く小さくなっていき難しくなっていきます。
ここでも :has() を利用して、チェックされているレベルによってCSSカスタムプロパティの値を変更して実現しています。

.wrapper {
    --ball-size: 50%;
    --ball-animation: slide-down;
    --ball-animation-duration: 5s;
    --ball-animation-delay: 1.5s;

    &:has(.ball[value^='lv']:checked) {
        --ball-animation-delay: 2.5s;
    }

    &:has(.ball[value='lv1']:checked) {
        --ball-size: 40%;
        --ball-animation-duration: 4s;
    }

    &:has(.ball[value='lv2']:checked) {
        --ball-size: 30%;
        --ball-animation-duration: 3s;
    }

    &:has(.ball[value='lv3']:checked) {
        --ball-size: 25%;
        --ball-animation-duration: 2s;
    }

    &:has(.ball[value='lv4']:checked) {
        --ball-size: 20%;
        --ball-animation-duration: 1s;
    }
}

.ball {
    width: var(--ball-size);
    height: auto;
    aspect-ratio: 1;
    animation: var(--ball-animation) var(--ball-animation-duration) linear
        infinite var(--ball-animation-delay);
}

ミス画面

ボールをタッチできないとミス画面が表示されます。

背景部分がすべて <input type="radio"> になっていて、ボールがタッチできないと背景部分がチェックされミス画面が表示されます。

タッチできないとミス画面を表示

<!-- Out area -->
<input type="radio" name="playableState" value="miss" class="out" />

<!-- Miss modal -->
<div class="miss modal"></div>
.wrapper:has(.out:checked) .miss {
    display: grid;
}

ミス画面からの復帰

ミス画面にあるretryボタンを押すとゲーム画面に戻ります。

これは背景と同じname値の <input type="radio"> になっていて、チェックされると背景のチェックが外れミス画面が非表示になります。

リトライ画面

<label class="button -lv1">
    <input
        type="radio"
        name="playableState"
        value="lv1_retry"
        class="retry"
    />
    Lv1 Retry
</label>

すべてクリア

ミスなくすべてクリアすると特別なクリア画面が表示されます。

背景に紙吹雪のような演出を入れていて、こちらも当然HTMLとCSSだけで作っています。
紙吹雪はこちらの実装をほぼそのまま使わせていただきました。ありがとうございました!

すべてクリアした画面

まとめと後書き

実はLTの段階では、リトライからの復帰だけHTMLとCSSだけでは上手くいかずにJSを使っていて「ほぼHTMLとCSSだけで簡単なゲームを作ってみた」というネタでした。

ラジオボタン使ったらJS使わなくてもいけるのでは?とLTの質問でもらって、確かにと思ってやってみたらできました。
こういったフィードバックがもらえるのも人前で発表することの利点ですね。

実際に :has() を使った実装をすることで、革新的な機能だということを実感することができました。なんとなく使い所も見えてきて実務でも、これは :has() 使えば簡単にできるかもという場面も増えてきました。

最近はCSSの進化がすごいので今後も新しい機能を積極的に使っていきます。

RightTouch

Discussion