ブラウザで動くドット絵なゲームを作る！

作りました！
撃って避けてスコアを稼ぐ、シンプルなゲームです。

<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/25a7638f-a81c-ed89-2f0e-a2e01d1b2a4c.png">

**アドベントカレンダー**でのゲームふたたび。
去年はランゲームでしたが、今年は**シューティングゲーム**になりました。

https://hasegawa-campfire.github.io/fire-shooter-san

:::note warn
わりと音が出ます
:::

やはり今回も、思うところや紆余曲折はイロイロありました。
ありましたがそれは note にまとめて、こちらの Qiita は技術的なお話です。

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

## 技術概要

前回のゲームはとても Webフロントらしく HTML や CSS が活躍していました。
が、今回は **canvas** でゴリゴリ描いています。
なので Webフロント特有感はだいぶ薄くなりました。

### 型

書いたものがビルド無しでそのまま動く。そんな手軽さは前回と同じ。
だけどそれなりに型安全、というかエディタの恩恵を得たい！
ということで **JSDoc** を書きました。

```js
/**
 * @param {number} num
 */
function toDelimited(num) {
  return num.toLocaleString('en-US')
}
```

そして最低限の `tsconfig.json` を添えておきます。

```json
{
  "compilerOptions": {
    "target": "es2017",
    "module": "esnext",
    "moduleResolution": "node",
    "strict": true,
    "allowJs": true,
    "checkJs": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
    },
    "outDir": "./dist"
  }
}
```

※ 詳しそうな記事

https://qiita.com/terrierscript/items/a9826bc58d550d1b2764

https://zenn.dev/qnighy/articles/56917d9bf9077b

### Import Maps

基本すべて手作りですが、ランキング周りに firebase は利用しています。
node モジュールは型が効いて便利ですね。ビルドで取り込めるのもいい。
でも素の状態では動かなくなるので、普段は CDN で済ませたい。

Import Maps はそれを叶えてくれます。

```html
<script type="importmap">
  {
    "imports": {
      "firebase/app": "https://www.gstatic.com/firebasejs/9.6.0/firebase-app.js",
      "@/": "/src/"
    }
  }
</script>
```

```js
import { initializeApp } from 'firebase/app'
import Hoge from '@/hoge.js'
```

素で実行されるとき、シンプルに import 先を差し替えてくれます。
よくある `@` なエイリアスも素で使えるようになったり。
型も効かせるなら 実際に node モジュールをインストールしておきましょう。

### タッチ操作の抑制

去年、タッチ操作の余計な挙動を抑たくていくつかの方法を書きました。
しかし。

```css
.hoge {
  touch-action: none;
}
```

これだけで良かったんですね。勉強不足でした。

ただ `setTimeout()` を使っていると問題が。
`touch-action: none` の要素がタッチされるたび、タイマーの実行がしばらく（300ms？）ブロックされるようでした。

おかげで画面を連打するとゲームが 3fps くらいに。ひどい。
`pointer-events: node` などで触れないようにもしてみましたが、やっぱりダメ。

幸い `requestAnimationFrame()` は影響を受けないようだったので、タイマーから置き換えてひとまずは解決しました。
でもこれ、仕様的にはリフレッシュレート依存なんですよね。
フレームレート制御にはちょっと悩みます。

### Top-Level Await

やっていないけど紹介したかったこと、その1。
async 関数の中でしか使えなかった await が、関数の外でも使えるようになりました。

```js
// 利用イメージ
import { loadMusic } from 'xxxxxx'
const hoge = await loadMusic('hoge.mp3')
```

ロード中とか考えずに処理が書けそうなので使いたかったんですが。
直近のモダンブラウザではみんな動くんですが！
バンドラーさんが辛そうな目をしているので諦めました。

### JSON Modules

やっていないけど紹介したかったこと、その2。
JSON ファイルを直接 import 出来るようになりました。

```js
import imageMap from './image.json' assert { type: "json" }
```

以前からよく使われてきましたが、ブラウザで動くようになったのはごく最近＆ごく一部。
この構文にしても TypeScript で通るようになったのはごく最近のこと。
なので当然ですよね。
バンドラーさんに知らん顔されたので諦めました。

余談ですが。
同様に HTML を import 出来るようになる HTML Modules という技術もあるそうです。
というかずっと待ってます。**Web Components に光あれ！**

## ドット絵の表示

閑話休題。
ブラウザでドット絵をキレイに表示させたい。そんな時、気をつける点がいくつかあります。

### 補正をかけない

|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/a3143a54-323c-04d2-4297-2d92b936376f.png">|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/61fd4c4c-53d6-7997-0163-d7f65ac1e633.png">|
|---|---|
|補間あり3倍|補間なし3倍|

ブラウザは画像の拡大や縮小でなめらかな補間をかけます。
しかしドット絵ではそれが困りもの。
とりあえず補間をやめさせてみましょう。

```css
img, canvas {
  image-rendering: pixelated;
  image-rendering: crisp-edges;
}
```

```js
const context = canvas.getContext('2d')
context.imageSmoothingEnabled = false
```

CSS の場合と、canvas 2d の場合。
ところで `pixelated` と `crisp-edges` はどう違うんでしょうね？
説明を読んでも、実際の表示を見てもよく分かりませんでした。

### 整数倍にする

|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/95c81ccd-dac8-c36e-ffce-449660dbb94b.png">|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/e6160f4d-a7d9-c3f6-b356-24c13f1aa73a.png">|
|---|---|
|補間なし3.5倍|補間なし3倍を補間ありで元の3.5倍サイズに|

補間を切ればエッジはとても簡単に際立ちます。
が、整数倍以外で拡大すると、なんとも違和感のある歪んだ見た目に。
市販のゲームでもたまに見かけますね。標準で歪んでいるもの。

とはいえ、目的のサイズにまで拡大したいという場面の方がほとんどでしょう。
一度、最も近い整数倍まで補正なしの拡大をしてから、最低限の補正で目的のサイズにまで拡縮するという手もあります。
そもそも補間なしでも倍率によっては歪みも気にならないですし、どちらが良いかは感覚にもよります。

### デバイスピクセルを意識する

整数倍ならキレイ。でもちょっと待って。
CSS の1ピクセルとデバイス画面の物理的な1ドットは違うものです。
Retina は1ピクセルで2ドットとか、そんな昔ばなしではありません。

|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/4c05519f-8ea9-1c50-6a3e-7823426d8af7.png">|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/61fd4c4c-53d6-7997-0163-d7f65ac1e633.png">|
|---|---|
|画像サイズ（10x9）をpx指定|1.25で割った8x7で表示|

私の環境ではPCが1ピクセルあたり1.25ドットで、スマホは1.7ドットです。
つまり1ピクセル自体が補間ありでボケるか、補間なしで歪みます。
対策としては、目的のサイズをデバイスピクセル比で割ります。

```js
// 画像をドットバイドットにする
img.width = img.naturalWidth / devicePixelRatio
```

サイズだけでは無く位置の考慮も必要かもしれません。
とはいえ、これでだいたい画像のドットと画面のドットが合います。

### 避けたいこと

個人的に。感覚的に。
ドット絵を謳うのなら、できればドットの単位を守って欲しいなあと思う。
そんな例です。

|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/d9a750ec-4d4e-9b4d-3ac0-793cc6d0f6ce.png">|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/ff080656-cbc3-4481-c57a-f0ad691bf116.png">|
|---|---|
|半端なズレ|半端な拡大縮小|

|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/3b35b008-d8cb-4391-3e23-0cfb95f0be60.png">|<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/846293/7cb032ca-d8af-a88d-4afd-a2c3ec7d62e5.png">|
|---|---|
|そのまま回転|文字だけキレイ|

とはいえ視認性との兼ね合いもあるでしょう。
あくまで雰囲気やイメージ作りだったり、HD-2D など表現の手法もイロイロ。
いろいろです。

ちなみにただドットの単位を合わせるだけなら、実装としてはシンプルな話。
一度低解像度でひと通り画面を描画して、最後に拡大するだけ。
ズレようが無いですね。融通も効きませんが。

### そのほか

更に感覚的な内容で、具体的でもなく恐縮なのですが。
なんとなくですよ。回転、拡大縮小、半透明の利用は、あまりにも多用したり、とてもなめらかな変化だと逆にチャチにも感じられてしまう。
そんな印象があります。

とはいえ、これは間違いなくやり方次第。
アニメーションを間引いて、利用を最低限に抑えれば無難には収まってくれる。かも。
その程度のボヤきです。

ところで昔のゲームのリマスターなどで解像度が上がり、逆に安っぽく感じてしまう事はないでしょうか。
あれもどこかに上手い言葉や分析がまとまっていたりするんでしょうかね。

## おわりに

時間が余れば WebGL にも対応するつもりでしたが、とにかく駆け抜けるだけの1ヶ月でした。
かかったなあ。

紹介できませんでしたが、ゲーム用のライブラリも改めて手作りしています。
技術的な見どころもなく機能も不足気味ですが、シンプルな書き味で覚えることもたぶん少なめ。
一部の作りを見直したら、いつか、紹介を書く機会もあるかもしれません。

いつか。

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

## 補足

※ FIRE SHOOTER SAN リポジトリ

https://github.com/hasegawa-campfire/fire-shooter-san/

※ 去年の記事

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