Vue.jsと物理演算とElectronで仕事中にデスクトップでお寿司をつまめるようになったのでソースと解説【クソアプリ】

この記事は[クソアプリ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/kuso-app)の7日目です。昨日は@fujit33さんの[【待ってました】ラーメンにコショウをかけるためのアプリを作りました！](https://qiita.com/fujit33/items/93d0b36f17816517b1b4)でした :relaxed::ramen:　... オチまで秀逸

## :sushi:作ったおすし:sushi:

**（背景に見えているのはこのアプリのコミットログです）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/f25211a6-a4dc-26ca-d357-b46a332ad4e9.png)

リモートワーク中のちょっとした待ち時間、みなさんどうしてますか？
ダウンロードが遅い時・ビルドが長い時・イラレがクラッシュした時... :innocent: 

「:thinking:ちょっと手持ち無沙汰だけどTwitter見るほどでも（仕事しろ）」...そんな時、**軽くお寿司つまんだりしたくなりませんか？**

なりますよね？この記事を見たあなたもきっと今すぐお寿司をつまみたい気持ちに心が支配されたはず。

そんな時にこのアプリを起動します。
https://github.com/yuneco/osushi-desktop#download

1.デスクトップの左上に、おもむろにお寿司が流れ出します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/083173be-fa70-6528-6590-b1c63f3bc042.png)

2.好きなおすしをつまみましょう
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/f534f0b5-9683-5503-bc98-432e30eb4519.png)

3.優しく積むもよし、荒ぶるお気持ちをぶつけるもよし
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/73784529-214d-7179-5d38-3f0b064274d4.png)

4.たまにお昼寝中の猫様も流れてくるので丁重に扱いましょう
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/8ca71698-27c4-af77-f768-d85733dd25cb.png)

5.良い積みができたらスクショをTwitterに流しましょう（隙あらば宣伝

![all.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/796be59e-5459-f304-05be-4599cf9cb811.gif)

## :sushi:なぜ作ったか:sushi:

繁忙期で残業続きの1週間が終わり、ようやく迎えた12月5日土曜日（おととい）...

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">今日気づいた一番怖いこと報告しますー<br><br>（完全に忘れてた） <a href="https://t.co/0n4u5vZCtT">pic.twitter.com/0n4u5vZCtT</a></p>&mdash; ゆき (@yuneco) <a href="https://twitter.com/yuneco/status/1335175067383128064?ref_src=twsrc%5Etfw">December 5, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


あと2日wwww もうお寿司食べるしかないじゃないですかwww

## :sushi:技術と解説:sushi:

ここからは少しだけ真面目に解説。
ソースコード：https://github.com/yuneco/osushi-desktop

### 使ったフレームワーク・ツールなど

- [Electron](https://www.electronjs.org/): webアプリをappやexeにできる魔法使い。...と見せかけてブラウザー(Chromium)丸ごと抱き込んで動かす実は筋肉系。力 is パワー:muscle:
- [Vue.js](https://vuejs.org/): webアプリがサクッと作れる偉い子。たまに「でもそれreactの後追いだよね？」って言われて泣く。今回はVue3 + composition-apiです
- [matter.js](https://brm.io/matter-js/): おすしが落ちたり積まれたりするために必要な物理演算をやってくれる天才。冬場はマシンを温めるのにも最適。
- [TypeScript](https://www.typescriptlang.org/): 理屈っぽくて面倒なTSちゃんでだけど、クソアプリみたいに勢いで書きなぐる時にはむしろ頼りになる
- [electron-builder](https://nklayman.github.io/vue-cli-plugin-electron-builder/): Vue-CLIで使えるプラグイン。Vueのプロジェクトに一発でElectronを導入してくれる神の遣い。正直何も理解しないでとりあえずコマンド叩いたら使えた:angel:
- [icon-gen](https://github.com/akabekobeko/npm-icon-gen#readme): 配布用のアイコンをまとめて作ってくれる気の利く子
- [Adobe Illustrator on iPad](https://www.adobe.com/jp/products/illustrator/ipad.html): おすしの絵を描くよ！便利だけどバグも多いよ！

### Step1: Vueのプロジェクトを作る

Vue-CLIの`vue create`を使って、普通に好きな感じの設定で作ります。
ただし、Routerを使いたい場合は`HistoryMode使う？`の質問にNoで答えてハッシュモードにする必要あり。多分気をつけるのはそれくらい

### Step2: Electronの導入

中身の開発に入る前に、そのままElectronを導入します。

```sh
cd 作ったVueプロジェクト
vue add electron-builder
```

バージョンとか聞かれると思うけど、適当に新しいの選らんどけばOK

終わったら↓でデバッグ起動。見慣れたVueのHelloWorldが`Electron`って名前の独立したアプリで表示されるはず。

```sh
npm run electron:serve
```

### Step3: ウインドウを透過させる

ここら辺から中身の話。
今回のおすしアプリはウインドウの背景を透過させるのがマストです。まずそこからやってみます。

透過自体は簡単で、プロジェクトのルートに生成されている`background.ts`ってファイルに設定を追加するだけ。

```ts:background.ts
async function createWindow() {
  // Create the browser window.
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    transparent: true, // ✨追加
    frame: false, // ✨追加
    resizable: false, // ✨追加
    backgroundColor: '#00FFFFFF', // ✨追加
    hasShadow: false, // ✨追加
    alwaysOnTop: true, // ✨追加
    webPreferences: {
      enableRemoteModule: true, // ✨追加
      nodeIntegration: true
    }
  })
  win.setIgnoreMouseEvents(true, { forward: true });
  // ...略
}
```

これで画面は表示されるけど背景は完全に透過して、かつクリックしてもすり抜けてデスクトップや他のアプリを触れるようになります。

各設定項目の意味は大体名前の通りなんだけど、1箇所`hasShadow`だけ注意。これがデフォルトの`true`のままだとアニメーションの残像が残ってチラツキが出るので`false`が良いです。影をつけたい場合はCSS側でやるのが吉。

あ、あとFAQなのでググったら出てくるけど、devtoolが出てると透過が無効になるので、最初にdevtoolを別窓で出すようにしておきましょう。

### Step4: 透過させつつコンテンツだけはクリックさせる

で、ここまでは簡単だった。1,2時間でできてこれなら余裕じゃーんって思ってた土曜の午後。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/23d9198e-a769-f33d-ab96-5f5a57772fb5.png)

なんだけどこの次がちょっと関門。...全部透過させちゃったから何もクリックできないのwww

何かの設定でできるでしょ 余裕余裕...って思ってたらできないらしい...まじか:confounded: ...でググった結果、

[Electronと透過ウィンドウ - 特定の要素ではマウスイベントを受け取る](https://qiita.com/fiji/items/59294b9f557be3f7f45b#%E7%89%B9%E5%AE%9A%E3%81%AE%E8%A6%81%E7%B4%A0%E3%81%A7%E3%81%AF%E3%83%9E%E3%82%A6%E3%82%B9%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E3%82%92%E5%8F%97%E3%81%91%E5%8F%96%E3%82%8B)

どうやら

- クリックしたい要素に`mouseenter`した → Electronのクリック透過を無効に
- クリックしたい要素から`mouseleave`した → Electronのクリック透過を再度有効に

しないといけないとのこと。。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/251a18a4-f1d5-5900-6c3b-57a6af37b69c.png)


せっかくなのでVue3らしくcomposition-apiで実装します。

```ts:/src/compositions/useClick.ts
import { onMounted, onBeforeUnmount, Ref } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Electron = require('electron')

const onErnter = (ev: MouseEvent) => {
  ev.preventDefault()
  Electron.remote.getCurrentWindow().setIgnoreMouseEvents(false)
}

const onLeave = (ev: MouseEvent) => {
  ev.preventDefault()
  Electron.remote
    .getCurrentWindow()
    .setIgnoreMouseEvents(true, { forward: true })
}

const resolveRef = (elRef: Ref) => {
  const value = elRef.value
  if (!value) {
    return null
  }
  return value as HTMLElement
}

const useClick = (elRef: Ref) => {
  onMounted(() => {
    const targetDom = resolveRef(elRef)
    targetDom?.addEventListener('mouseenter', onErnter)
    targetDom?.addEventListener('mouseleave', onLeave)
  })
  onBeforeUnmount(() => {
    const targetDom = resolveRef(elRef)
    targetDom?.removeEventListener('mouseenter', onErnter)
    targetDom?.removeEventListener('mouseleave', onLeave)
  })
}

export default useClick
```

これを使って`Clickable`コンポーネントを作って...

```vue:/src/components/Clickable.vue
<template>
  <div class="ClickableRoot" ref="el">
    <slot />
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
import useClick from '../compositions/useClick'
export default defineComponent({
  setup() {
    const el = ref(null)
    useClick(el)
    return { el }
  }
})
</script>
```

クリックに反応させたい要素をラップすればOK

```vue
<template>
  <Clickable>
    <button>おせるよ！</button>
  </Clickable>
</template>
```

今回は単純に四角形の当たり判定しかとってないけど、もっと自由な形で正確な当たり判定が必要ならSVG使うしかないかな...と

### Step5: おすしを表示する

とりあえずおすしのような何かを表示します。

![maguro.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/a259c30d-1d5f-f961-880f-257e62dc4870.png)

OK...どう見てもMAGUROですね:muscle::fish:

[コミット的にはこのあたり: 寿司のベース作成](https://github.com/yuneco/osushi-desktop/commit/d2401ece6853ec9ba64f0a0049c0bd75a59149e0)

### Step6. おすしに物理演算を適用

まがりなりにもおすしが出たので、次に物理法則をお寿司に適用します。去年ネタで作った[●●WebGL(PIXI.js + glsl)と物理演算(matter.js)で可愛い絵本風タピオカ作ったので解説●●](https://qiita.com/yuneco/items/f6a8978a0d608460123f)のソースをがっつりコピペして土台を作ります。

[コミット的にはこのあたり: おすしに物理演算を適用](https://github.com/yuneco/osushi-desktop/commit/f48a0aa82af7bb78919e6b5aca7a8eea868bfa6f)

今回はCanvas(PixiJS)ではなくHTML(Vue)で画面を表示するので、matter.jsからの更新をうけとって、物理演算世界のおすし（ただの長方形）の座標や角度が変わっていたら、その値をVue側にも反映させます。

[/src/components/FlyingSushi.vue#L48](https://github.com/yuneco/osushi-desktop/blob/7d06e09b6e4f592826f30f47e39c7f5c977fa424/src/components/FlyingSushi.vue#L48)

```ts:/src/components/FlyingSushi.vue
    onMounted(() => {
      // 表示するおすしの座標
      state.sushiPos = new Pos(props.initialPos.x, props.initialPos.y)
      // 物理世界におすし（長方形）を投入
      props.world?.addRect(
        props.initialPos.x,
        props.initialPos.y,
        90,
        35,
        // 物理世界で座標が更新された時のコールバック
        xyr => {
          state.sushiPos = new Pos(xyr.x, xyr.y, r2a(xyr.r))
        }
      )
    })
```

ここまででおすしを積めるようになりました。

![oss1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/80794003-6cc7-ecec-5c03-33f076f0ae48.gif)

### Step7. 寿司レーンをつくる

[コミット的にはこのあたり: おすしを回しました](https://github.com/yuneco/osushi-desktop/commit/162cf78f5523ad93752e525f0ef11da0b2225de4)

これも特に解説はいらないのでパス。
`SushiRail`コンポーネント（寿司レーン）を作り、その中で一定時間ごとに`TurnDish`コンポーネント（皿）を生成して左から右にアニメーションさせます。スクロールアウトしたあたりで適当に削除するのを忘れずに。

### Step8. 可愛くする

最後にダミーだったおすしの画像をちゃんとしたものに差し替えます。

今回は10月に正式リリースしたばかりのiPad版Adobe Illustratorを使っておすしを描いて、SVGで書き出します。[^svg]

[^svg]: ただしiPadイラレの吐くデータは、クリッピングマスクを使っているとなかなかカオスな感じになるので、一旦デスクトップのイラレで整形する方がトラブルを避けられそうです

![illustrator.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/6a670711-0717-26fd-79a8-7a40dbea4a2c.jpeg)

ついでに[/src/logics/SushiAssets.ts](https://github.com/yuneco/osushi-desktop/blob/master/src/logics/SushiAssets.ts)あたりにすしネタの型定義とかも追加します。

```ts:/src/logics/SushiAssets.ts
/** すしネタ */
export type SushiNeta =
  | 'uni'
  | 'toro'
  | 'tamago'
  | 'salmon'
  | 'neko'
  | 'maguro'
  | 'kohada'
  | 'ikura'
  | 'ika'
  | 'ebi'

/** お皿の色 */
export type DishColor = 'dishAka' | 'dishAo' | 'dishGin' | 'dishKin'
```

### Step9. ビルド

正直リリースしたところでこのクソアプリ使う人もいないと思うんだけど、せっかくなのでビルドもします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/e64b9a01-e006-ca4b-5e35-2712c0050646.png)
~~クソアプリといえども~~アイコンついて単体アプリの形になるとやっぱり気分が上がりますね。

アイコンの作り方は[【自作デスクトップアプリ】Electronにアイコンを設定する方法【Vue.js】](https://blogenist.jp/2019/09/06/9121/)を参照。

今回はクソアプリなので署名とかはしません。ちゃんと公開する場合（特にMac）は、署名つけてAppleの公証も通さないと普通のダブルクリックで起動できない[^macapp]ので注意。

[^macapp]: コンテキストメニューから開いて確認ダイアログを出さないと起動できない。有償のDeveloperアカウントが必要

## まとめ

- お手軽にデスクトップアプリを作りたい時の選択肢としてElectron + Vueはいい感じ。
- おすしは良いものです

明日(12/8)は @namosuke さんです！