HTMLでマインスイーパー風味のゲームを作った

ハートの在処を見つけよう！
数字に合わせてハートを置くだけの、シンプルなゲームです

<img width="240" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/f9c7c96f-c868-1180-50c9-ed257d222a00.png">
<img width="240" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/9e83b8dd-f82e-44ce-cc0e-6b7adccea8cd.png">

ミニゲームの季節がやって来ました。今年は来ないはずでした。
こんにちは！
アドベントカレンダーでミニゲームを作るWebフロントエンジニアです。

今年はマインスイーパーのような風味がするパズルゲームです。
風味だけです。あと、できる限りマイルドに仕上げました。

:::note warn
すこし音が出ます
:::

https://hasegawa-campfire.github.io/fire-seeker/

なんやかんやでイロイロありましたが、その辺りは以下のnoteへ。
こちらはこちらで実装の知見まとめです。

https://note.com/8kg/n/nabce5c69051a

## 技術概要と今回の知見

去年とあまり変わりません。
HTML / CSS / JavaScript / Web Components
そんな Web ページ。

強いて言うなら、ついに 60fps 的なメインループが消えました。
そして、ありふれて見慣れたイベント駆動へ。
アクション性とか無いですからね。今回。

なお今回もライブラリ利用はありません。
またビルドせずそのまま動かせる作りになっているのも同様です

※ローカルサーバーは別途ご用意ください
※公開にあたっては諸々ビルドしています

### el（仮）

いきなりですが。オレオレ実装。
HTML をコンポーネント利用するための簡易フレームワークです。
今年はテンプレートエンジンやリアクティブ機能が付いて更にそれらしくなりました。

具体的には下のような感じに書きます。簡単なカウンターの例です。
ここにはありませんが、分岐や繰り返し処理もあります。

```html:x-counter.m.html
<script type="module">
  import { setupEl } from '@/lib/el/index.js'

  export class XCounter extends HTMLElement {
    static {
      customElements.define('x-counter', this)
    }

    constructor() {
      super()
      setupEl(this, import.meta.document)
    }

    increment() {
      this.count++
    }

    decrement() {
      this.count--
    }
  }
</script>

<template>
  <button type="button" :onclick="() => decrement()">-</button>
  <button type="button" :onclick="() => increment()">+</button>
  <span :class="{ alert: count > 9 }">${count}</span>
</template>

<style>
  .alert {
    color: red;
  }
</style>
```

正直、もっさい感じもありますが。可能な限りはネイティブらしく。
これを HTML Modules として読めば Web Components として使えます。

Web Components は最も薄くコンポーネントを実現出来て、
HTML Modules は最も薄くコンポーネントファイルを実現出来る。

https://github.com/WICG/webcomponents/blob/gh-pages/proposals/html-modules-proposal.md

よく分かっていないですが DOM Parts が来たら、
更にテンプレートエンジンの実装も薄く出来るんでしょうか？

https://github.com/WICG/webcomponents/blob/gh-pages/proposals/DOM-Parts.md

ちなみに HTML Modules はまだまだ使えそうもないので、
ビルド前は Service Worker で、ビルド後は Vite プラグインの力で実現しています。

### CSS Nesting

ネイティブの CSS ネスト記法。今回ようやく使いました。便利。
ところで ::slotted には擬似クラスがネスト出来ないんですね。

```scss:NG
::slotted(div) {
  &:first-child {
    color: red;
  }
}
```

- `::slotted(div):first-child` もNG
- `::slotted(div:first-child)` ならOK

なお ::before はどちらもNG。
疑似要素は slot 直下ではないですからね。已む無し。
と思ったら `::slotted(input)::placeholder` は効くそうで。えー。

https://stackoverflow.com/questions/61626493/slotted-css-selector-for-nested-children-in-shadowdom-slot#61631668

### Shadow DOM 内の id と SVG

HTML において重複の許されない id 属性について。
これが規約的に Shadow DOM を越えても許さるかどうかハッキリしませんが。
少なくとも動作的に id は Shadow DOM を越えて機能しません。

https://stackoverflow.com/questions/56023322/are-duplicate-ids-allowed-in-separate-shadow-roots#56024664

ということでインライン SVG。
通常、ページ内に展開するなら id や class の重複に配慮が必要なシロモノですが。
これも Shadow DOM に閉じ込めれば、重複を気にしなくて良いということ。

id をユニークにしたり、合わせて filter や mask などを書き換える必要もなし。
ありがたい。

### コンテナのサイズ

主に cqw などの単位として便利に利用している CSS のコンテナクエリ。
そんなコンテナの宣言、つまり container-type には種類があって。

- インライン方向しか応じない `inline-size`
- 両方向に応じる `size`

であれば、縦横両方が使える `size` 以外の選択肢があるのかと思っていました。
これ、その方向において内側のサイズを無視するんですね。

つまり単に置かれた div を `size` にすると、内側に何があっても高さが 0 に。
だから必ず高さの指定が併せて必要になる。

contain の `inline-size` や `size` と同じですね。
と思ったらどちらも CSS Containment の範疇だそうで。
意識していませんでした。

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

### plus-lighter

画像処理ソフトのような重ね方を CSS だけで実現できる mix-blend-mode。
乗算・スクリーン・ハードライト・etc。しかし加算はありませんでした。
いや、無いと思い込んでいました。

でもあったんですね。それが plus-lighter。
やはりゲームの演出にはこれがあると心強い。

https://developer.mozilla.org/ja/docs/Web/CSS/mix-blend-mode

ただし、やや不安定なようで。
opacity や transform 及びその will-change などの影響下だと効かなくなる。
少なくとも手元（win11/chrome）ではそんな感じです。

transform 中に表示が云々とか、ちょっと懐かしみのある挙動ですね。

### View Transitions API

ページ遷移のアニメーションを手軽に実現できる View Transitions API。
本当にお手軽カンタン。

一発開始すれば、中間も終わりも意識せず放置できる。
最前面で別途アニメするので、遷移中の余計なページ操作も勝手に防がれる。
アニメーションを見慣れた CSS で書けるのも嬉しいところ。

https://ics.media/entry/230510/

ただ、ページ内の CSS アニメなどは遷移中に全て止まってしまう様子。
あくまでも表示されるのはページのスナップショットだからですか。

### 少しだけクロスフェード

技術要素ではなく、上のオマケみたいなミニ知見。

今回、ページ遷移は A → 真っ白 → B という順に切り替わります。
つまりフェードアウトしてからフェードインという感じなんですが。

この時、両者のタイミングを少しだけ重ねるとソフトめな印象に。
フラッシュのような切り替わりを認識させながらも、
変化の激しさをほんのり抑えて見せられる。

ページ切り替えに限らず、ぽつぽつと利用しています。

### inert 属性

マウスやキーボードなどユーザー操作を全て無効化できる便利属性 inert。
disabled に近いイメージですが、こちらはあらゆる要素が対象。
見た目は据え置きのまま、選択やフォーカス、その他諸々防がれます。

https://ics.media/entry/230406/

ゲームにおいては割と頻繁に制御が求められるトコロでもあり。
親元に属性ひとつ付けるだけで事足りるので大変助かりました。

### translate / scale / rotate

CSS の transform から個別にバラけた移動と拡大と回転。
なんやかんやで今回始めて利用しました。

やはり手軽さが増したり、別々にアニメーション出来るのはいいですね。

ところでそれぞれ実行順が肝心だと思うんですが。
その辺り、個別での利用だとどうなるんですかね。

### dialog とアニメーション

去年辺りから話題をとても見かけるようになった気がするタグ、第一位。
ようやく自分で使いました。dialog タグ。

標準でトップレイヤー、フォーカストラップあり。
大変便利ですね。
属性やプロパティだけでモーダルを開けたらもっと嬉しい。

https://www.tak-dcxi.com/article/implementation-example-of-a-modal-created-using-the-dialog-element/

https://blog.jxck.io/entries/2024-10-03/animation.html

そしてそのアニメーションに使える CSS もいくつか。

#### allow-discrete

連続性のない値をアニメーションさせる場合の新しい指定。
変化するタイミングが変わります。詳しくは上の記事で。

```css:example
div {
  transition: display 1s allow-discrete;
}
```

ところでこれ display の場合に、

- block → none なら 100% 時に変化
- none → block なら 0% 時に変化

という感じがちょっと気持ち悪いですね。
個別に振る舞いを用意しているのか。
それとも値に隠された重みが存在するのか。

#### starting-style

例えば display が none → block になって要素が出現した場合。

出来れば transition で opacity を 0 → 1 に変化させたいけれど、
display:none の opacity は 0 どころか値無しの扱いなので、
無し → 1 でアニメにならない。

そんな状況を解決してくれる、出現時の初期値を決める書き方。
それが `@starting-style`。詳しくは上の記事で。

```css:example
@starting-style {
  div {
    opacity: 0;
  }
}
```

ただ今回に関しては、使ったものの最終的にはやめました。
要素の出現と同時にアニメさせるのは、やや過負荷だったらしく。

結局、出現後に一拍置いてから class などを付けてアニメさせる。
そんな旧来のカタチに落ち着きました。

#### overlay

dialog を閉じる場合。
いきなり無くなるのではなく、アニメが終わってから無くなって欲しい。

display と同じだと思いませんか。100% 時に none になって欲しい。
ということでこれも allow-discrete の出番。その対象になるのが overlay。
詳しくは上の記事で。

```css:example
div {
  transition: overlay 1s allow-discrete;
}
```

### glob

ゲームには使ってないけれど、ビルドには使っている。
指定したパターンのファイルを列挙する定番モジュール。glob。

ついに node.js に備わりました！　実験的ですが。

ただしこれ、返すのは AsyncIterator です。（fs/promisesの場合）
定番モジュール的には globIterate() 辺り。
並べるとこう。

```js
import { glob } from 'glob'

for (const file of await glob('*')) {
  console.log(file)
}
```
```js
import { globIterate } from 'glob'

for await (const file of globIterate('*')) {
  console.log(file)
}
```
```js
import { glob } from 'node:fs/promises'

for await (const file of glob('*')) {
  console.log(file)
}
```

ちなみに promises ではない方の fs について。
glob() は古式ゆかしきコールバック形式で。
globSync() は定番モジュール同様です。

## おわりに

今年のゲームは早々から考えていたものの、結局うまくまとまらず。
そのまま年末を迎えて諦めていたら、後押しと思いつきから急遽別モノを開発。

そんな短期じゃ、技術記事なんて書ける内容無いだろうと思っていましたが。
結構あるもんですね。
色々と知識だけで実際には触れられていなかったとも言える。

新しい技術はもっと積極的に触っていかないと。
そして来年こそは元々構想していた方のゲームをカタチにしないと。

Webフロントはたのしいですね！
Webフロントはたのしいですよ！

## 補足

※ FIRE SEEKER リポジトリ

https://github.com/hasegawa-campfire/fire-seeker/

※ 2023 年の記事

https://qiita.com/koichi-hasegawa/items/865ce68ebf875eef1420

※ 2022 年の記事

https://qiita.com/koichi-hasegawa/items/af8a8cb962979836c6bc

※ 2021 年の記事

https://qiita.com/koichi-hasegawa/items/af96293c29d1f2474847

※ 2020 年の記事

https://qiita.com/koichi-hasegawa/items/1273eb88782e0f1d2835