Vue3 の CompositionAPI で p5.js を動かす


この記事は、[ニフティグループ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/nifty) 6日目の記事です。遅くなりすみません！
最近はシミュレーションの描画にハマっており、p5.js 触ってみたいなということで今回のネタにしました。
Vue.js のコンテナ内で、CompositionAPI の記法で p5.js を動かしたので紹介します。

CompositionAPI についての説明は割愛します。以下の記事を参考にさせていただきました。

- [Vue 3 Composition API を使ってみよう](https://qiita.com/azukiazusa/items/1a7e5849a04c22951e97)


# 作ったもの
振動するボールを作って動かすことができます。
マウスをクリックするとその位置にボールが集まってきます。
丸い物体が絶妙に揺れながら近づいてくるの可愛すぎる。

![brown.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267904/0af95c12-4faa-d3c9-265c-99d000538e98.gif)


## p5.js の紹介
p5.js はクリエイティブコーディングのための JavaScript のライブラリです。
アートやシミュレーションの描画として利用できます。

- https://p5js.org/examples/

ここでは基本的な使い方を紹介します。

- setup(): 最初に呼び出され、canvas の大きさや背景色を指定
- draw(): 1/60秒ごとに呼び出される
- その他、円状の物体を表示する関数や、色を指定する関数、マウスの位置や状態を取得する関数が揃っている

例えば、 canvasを生成し、マウスがクリックされるとそのマウスの位置に円を描画するコードはこちら。

![p5_sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267904/f65db025-ada3-6192-23ae-ebaed2b73b1f.gif)


```javascript:sample.js
function setup() {
    createCanvas(300, 300);
    background(0);
}

function draw() {
    // 1/60秒に一回、canvas の中心に直径50の円を描画
    ellipse(width/2, height/2, 50, 50);

    // マウスがクリックされていれば、drawCircleAtMouse 関数を実行
    if (mouseIsPressed) {
        drawCircleAtMouse();
    }
}

function drawCircleAtMouse() {
    // マウスの位置に直径 5 の円を描画
    ellipse(mouseX, mouseY, 20, 20);
}
```


# コンテナの作成
ディレクトリ構成

```
├── Dockerfile
├── docker-compose.yml
```


Dockerfile

- 今回はvue-cli でプロジェクトを作成

```Dockerfile:Dockerfile
FROM node:16.3-alpine

WORKDIR /usr/src/app

RUN apk update && apk add curl && apk add vim
RUN npm install -g npm && npm install -g @vue/cli
```


docker-compose.yml

```yaml:docker-compose.yml
version: '3.8'
services:
  p5_vue_container:
    build: .
    tty: true
    volumes:
      - ./:/usr/src/app
    ports:
      - "8080:8080"
```

コンテナを作成して中に入る

```bash
$ docker compose up -d
$ docker compose exec p5_vue_container sh
/usr/src/app #
```

プロジェクト作成

- 今回は Vue.js で p5.js を使えれば良いので、preset は Default (Vue 3) を選択

```bash
/usr/src/app # vue create p5-test

Vue CLI v4.5.15
? Please pick a preset: Default (Vue 3) ([Vue 3] babel, eslint)
? Pick the package manager to use when installing dependencies: NPM
```

以下のファイルを追加

- p5-test/js/brown.js
- p5-test/js/class/ballClass.js

最終的な主なディレクトリ構造

```
├── Dockerfile
├── docker-compose.yml
├── p5-test
    ├── src
        ├── App.vue
        ├── js
            ├── SetupP5.js
            ├── class
                ├── ballClass.js
```

# Vue.js コンテナ内で p5.js を使う
こちらを参考にさせていただきました。

- [Vue.jsでp5.jsを使ってみた](https://qiita.com/mitsuya_bauhaus/items/b6f3d1aec07a9e07bb3a)

コンテナ内で p5.js をインストール

```bash
/usr/src/app # cd p5-test
/usr/src/app/p5-test # npm install --save p5
```

利用したいファイル上で p5 をインポート

- 今回はvue ファイル上でインポート

```javascript
import p5 from 'p5';
```

# vue ファイルの作成
App.vue を以下のコードに置き換える

```vuejs:App.vue
<template>
  <div>
    <h1>Create Canvas</h1>
    <!--ボール作成ボタン-->
    <button v-on:click="creatBalls">Create Balls</button>
    <!--全ボール削除ボタン-->
    <button v-on:click="clearCanvas">Clear Canvas</button>
  </div>
  <br>
  <!--canvas を配置する位置-->
  <div id="canvas"></div>
</template>
<script>
import { onMounted, ref } from 'vue';
import p5 from 'p5';
import { p5Setup, addBalls, clearBalls } from './js/SetupP5';

export default {
  setup() {
    const P5 = ref();
    
    // マウント時に canvas を生成
    onMounted(() => {
      P5.value = new p5(p5Setup);
    });

    // ボールを追加
    const creatBalls = () => {
      addBalls();
    };

    // ボールを全て削除
    const clearCanvas = () => {
      clearBalls();
    };
    
    return {
      P5,
      creatBalls,
      clearCanvas
    };
  }
};
</script>
```

- ボールを作るボタンとボールを全て削除するボタンを用意
- canvas を特定の位置に出すためのタグを用意
    - 参考: [《JavaScript》p5.jsでcanvasを任意の場所に配置する方法。](https://qiita.com/uto-usui/items/3dc216f7c1bab3b90e0e)
- p5.js のインスタンスモードを利用するため、setup() で p5 変数を ref() で宣言
    - 参考: [vue.jsの instance mode でp5.jsを動かす（メモ）]
(https://qiita.com/PmanRabbit/items/e5253249df108c904640) 
    - 参考: [p5.js を instance mode で使う](https://qiita.com/turusuke/items/c92a13602523d6378cc0)
- CompositionAPI では、基本的に setup() 内に関数などをまとめて記述できる
    - setup() 内の onMounted で、マウント時に p5.js のインスタンスを作成
    - ボール追加・削除ボタンを押したときに発火する関数も setup() に記述

# p5.js のファイルを作成
p5.js でボールを描画するファイルと、ボールをオブジェクトとして管理するための Ball class の二つのファイルを作成

## p5.js ファイル （SetupP5.js）
```javascript:SetupP5.js
import { Ball } from './class/ballClass';

// 生成されるボールの数
const numBalls = 2;
// canvas の背景色
const bgcol = 25;
// ボールクラスを保存する配列
let ballArr = [];
// ボール追加フラグ
let addBallsFlag = false;
// ボール削除フラグ
let clearBallsFlag = false;

// vue ファイルで p5 インスタンスに渡す関数
const p5Setup = function(p5) {
  // はじめに呼ばれる
  p5.setup = () => {
    // canvas 生成
    const canvas = p5.createCanvas(500, 500);
    // <div id="canvas"> に canvas を配置
    canvas.parent('canvas');
    // canvas の背景色
    p5.background(bgcol);
    // canvas 内の動きをなめらかにする
    p5.smooth();
    // draw()を 1/30秒ごとに実行
    p5.frameRate(30);
  };

  // 1/frameRate 秒ごとに呼ばれる
  p5.draw = () => {
    p5.background(bgcol);
    for (let i=0; i<ballArr.length; i++) {
      const thisBall = ballArr[i];

      // ボールの位置を計算
      thisBall.update(p5)

      // マウスが押されていれば、マウス方向へボールを移動
      if (p5.mouseIsPressed) {
        thisBall.agg(p5, p5.mouseX, p5.mouseY);
      }
    }

    // ボールを追加
    if (addBallsFlag) {
      for (let i=0; i<numBalls; i++) {
        const thisBall = new Ball(p5);
        thisBall.drawBall(p5);
        ballArr.push(thisBall);
      }
      addBallsFlag = false;
    }

    // ボールを全て削除
    if (clearBallsFlag) {
      ballArr = [];
      clearBallsFlag = false;
    }
  };
};

// ボール追加フラグの変更
const addBalls = () => {
  addBallsFlag = true;
}

// ボール削除フラグの変更
const clearBalls = () => {
  clearBallsFlag = true;
}

export { p5Setup, addBalls, clearBalls };
```

- p5Setup 関数内に、p5 関連の処理を全て記述し、vue ファイルの p5 インスタンスに渡す
- ボールの追加・削除は、vue 側からフラグを変更する関数を呼び、フラグをもとに p5Setup() 内でボールの追加・削除を実行


## Ball class
```javascript:ballClass.js
export class Ball {

  constructor (p5) {
    // ボールが描画される座標
    this.x = p5.random(p5.width);
    this.y = p5.random(p5.height);

    // 描画されるボールの半径、色、色の透明度
    this.radius = p5.random(10) + 10;
    this.fillcol = p5.color(p5.random(255), p5.random(255), p5.random(255));
    this.alph = 0;
  }
    
  // ボールを描画
  drawBall(p5) {
    // ボールの輪郭の線を消す
    p5.noStroke();
    // 色を設定
    p5.fill(this.fillcol, this.alph);
    // (x, y) に半径 radius の円を描画
    p5.ellipse(this.x, this.y, this.radius*2, this.radius*2);
  }
  
  // ボールの位置を更新
  update(p5) {
    this.x += p5.random(-3, 3);
    this.y += p5.random(-3, 3);

    // 境界条件
    if (this.x > (p5.width + this.radius)) {
        this.x = 0 - this.radius;
    }
    if (this.x < (0 - this.radius)) {
        this.x = p5.width + this.radius;
    }
    if (this.y > (p5.height + this.radius)) {
        this.y = 0 - this.radius;
    }
    if (this.y < (0 - this.radius)) {
        this.y = p5.height + this.radius;
    }

    // 更新した座標で再描画
    this.drawBall(p5);
  }

  // マウスのクリック位置に集まる
  agg(p5, mouseX, mouseY) {
    const v = 0.02;
    // ボールとマウスの位置を計算
    let d = p5.dist(this.x, this.y, mouseX, mouseY)

    // 距離が 2 より大きければ、マウスに近づくように座標を更新
    if (d>2) {
        this.x += v*(mouseX-this.x) + p5.random(-10, 10);
        this.y += v*(mouseY-this.y) + p5.random(-10, 10);
    }

    // 更新した座標で再描画
    this.drawBall(p5);
  }
}
```

- ボール一つ一つをオブジェクトとして管理
- ボールの位置はランダムに -3 から 3 の範囲で動く
- マウスクリック時、マウス方向へ v の大きさ + -10 から 10 の範囲でランダムに移動
- 境界条件は端に来たら逆の端に移動する
   - 某 RPG と同じ 

# 動かす
プロジェクトのルートで実行

```bash
/usr/src/app/p5-test # npm run serve
```

localhost:<docker-compose.yml で設定したポート番号> にアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267904/01925110-0902-59ec-d66b-c211f22c6ea5.png)

# まとめ

今回は Vue.js のコンテナ内で、CompositionAPI の記法で p5.js を動かしてみました。
ほとんど CompositionAPI の旨みは使っていないですが、Vue.js で p5.js を動かせたので良しとします。
ボール同士の衝突や複数の相互作用を入れたり、他のモデルを実装して遊んでみようと思います。