Vue.jsでぷ○コンみたいなジョイスティックを作った

# 概要
タイトルそのままなのだけど
ぷ○コン作りました。

PWAの時代来ると思うし。
きっとVueで手軽にゲーム作りたいなぁってときにこういうのいると思うし。
移動のインターフェイスとしてはとても使い勝手がいいし。

(○にコンと言いつつ正確にはタップした場所を始点に差分を出してるのが同じなだけで見た目は別物)

# デモ
https://punidemo.firebaseapp.com/
たんにヒヨコ(？)を移動させるだけ。
マウスとタップどちらにも対応してるのでPCブラウザでもスマホでも動くはず。
リセットボタンは位置が初期位置に戻るだけ。

イメージ
<img width="383" alt="demo.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106608/081832f2-b167-6594-1cec-9a1a3ef041ce.png">

タッチ部分はこんな感じのが表示される
<img width="142" alt="pad.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106608/fd58295c-a647-7cb4-1687-6222070f95d9.png">

(以下説明用に円の色変え)
1番外側はタップした際の表示位置に固定で表示される。
<img width="128" alt="pad1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106608/a813b430-2f1a-b4c0-778d-bbdac4376d0b.png">

真ん中のはタップ位置。動かすとどこまでも追いかけていく。
<img width="127" alt="pad2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106608/f8bf2f26-ebc9-85e1-b5d7-7c103428d478.png">

1番内側のは一定距離まで動かすと距離が固定されるやつ。
正直これは見た目用なので機能としてはいらないやつ。
(タップの距離が中心と離れすぎるとどの方向に伸ばしてるのか分からなくなるので目印に)
<img width="117" alt="pad3.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106608/d65569d4-02e3-5207-2503-0f01809460f2.png">






# 環境
Vue.jsと書いてるけどデモ自体はNuxt.jsで作成。
試してないけど、nuxtの要素は特に無いのでたぶんvue-cliとかで作ったプロジェクトでも動くはず…？
vueは2.6.10
nuxtは2.10.2
PCのchromeとスマホのsafariでしか確認してない。

# コンポーネントの概要
タッチ位置を始点にして差分を検出、呼び出し元に対して
・x: 始点からの差分の単位ベクトルのx
・y: 始点からの差分の単位ベクトルのy
・v: 始点からの差分の絶対値
・rad: 始点からのラジアン
の4つを通知している。
※デモではradは使ってないし合ってるかも検証してない、なんとなく今後使いみちあるんじゃと思ってつけた

# ソースコード
```javascript:puni.vue
<template>
  <div
    style="position:absolute;width:100%;height:100%;top:0px;left:0px;z-index:1000;"
    @mousemove="mousemove($event)"
    @mouseup="touchend($event)"
    @mouseleave="touchend($event)"
    @touchmove="touchmove($event)"
    @touchend="touchend($event)"
    @mousedown="mousestart($event)"
    @touchstart="touchstart($event)"
  >
    <div
      style="user-select: none"
      :style="padArea.style"
      v-show="isMousedown"
    >
      <div style="position:relative;width:100%;height:100%;">
        <div :style="padHead.style" />
        <div :style="padRoot.style" />
      </div>
    </div>
  </div>
</template>

<script>
export default {
  mixins: [],
  components: {},
  head() {
    return {};
  },
  data() {
    return {
      isMousedown: false,
      padViewPosition: {
        x: 0,
        y: 0
      },
      padMovePosition: {
        x: 0,
        y: 0
      },
      padHead: {
        size: 48,
        edgeSize: 5,
        color: "#aaa5",
        zIndex: 1100,
        style: {}
      },
      padRoot: {
        size: 32,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1050,
        style: {}
      },
      padArea: {
        size: 100,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1010,
        style: {}
      }
    };
  },
  watch: {
    padMovePosition: {
      handler(newValue, oldValue) {
        const x =
          this.padMovePosition.x -
          (this.padViewPosition.x + this.padArea.size / 2);
        const y =
          this.padMovePosition.y -
          (this.padViewPosition.y + this.padArea.size / 2);
        const vel = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));

        // 呼び出し元へ通知
        this.$emit("input", {
          x: x == 0 ? 0 : x / vel,
          y: y == 0 ? 0 : y / vel,
          v: vel,
          rad: Math.atan2(y, x)
        });

        // タップ先端のstyle
        this.padHead.style = {
          position: "absolute",
          width: this.padHead.size + this.padHead.edgeSize * 2 + "px",
          height: this.padHead.size + this.padHead.edgeSize * 2 + "px",
          top:
            this.padMovePosition.y -
            this.padViewPosition.y -
            this.padHead.size / 2 -
            this.padHead.edgeSize +
            "px",
          left:
            this.padMovePosition.x -
            this.padViewPosition.x -
            this.padHead.size / 2 -
            this.padHead.edgeSize +
            "px",
          "z-index": this.padHead.zIndex,
          "border-radius": this.padHead.size / 2 + this.padHead.edgeSize + "px",
          border: this.padHead.edgeSize + "px solid " + this.padHead.color,
          "box-sizing": "border-box"
        };

        // タップの根本のstyle
        const padRootPosition = (() => {
          if (vel > this.padArea.size / 2) {
            let value = this.padArea.size / 2 / vel;
            return {
              x: x * value + (this.padViewPosition.x + this.padArea.size / 2),
              y: y * value + (this.padViewPosition.y + this.padArea.size / 2)
            };
          } else {
            return {
              x: this.padMovePosition.x,
              y: this.padMovePosition.y
            };
          }
        })();
        this.padRoot.style = {
          position: "absolute",
          width: this.padRoot.size + this.padRoot.edgeSize * 2 + "px",
          height: this.padRoot.size + this.padRoot.edgeSize * 2 + "px",
          top:
            padRootPosition.y -
            this.padViewPosition.y -
            this.padRoot.size / 2 -
            this.padRoot.edgeSize +
            "px",
          left:
            padRootPosition.x -
            this.padViewPosition.x -
            this.padRoot.size / 2 -
            this.padRoot.edgeSize +
            "px",
          "z-index": this.padRoot.zIndex,
          "border-radius": this.padRoot.size / 2 + this.padRoot.edgeSize + "px",
          border: this.padRoot.edgeSize + "px solid " + this.padRoot.color,
          "box-sizing": "border-box"
        };
      },
      deep: true
    },
    padViewPosition: {
      handler(newValue, oldValue) {
        this.padArea.style = {
          background: "#eee5",
          position: "absolute",
          width: this.padArea.size + this.padArea.edgeSize * 2 + "px",
          height: this.padArea.size + this.padArea.edgeSize * 2 + "px",
          top: this.padViewPosition.y + "px",
          left: this.padViewPosition.x + "px",
          "user-select": "none",
          "z-index": this.padArea.zIndex,
          "border-radius": this.padArea.size / 2 + this.padArea.edgeSize + "px",
          border: this.padArea.edgeSize + "px solid " + this.padArea.color,
          "box-sizing": "border-box"
        };
      },
      deep: true
    }
  },
  created() {},
  mounted() {
    this.padMovePosition = {
      x: this.padViewPosition.x + this.padArea.size / 2,
      y: this.padViewPosition.y + this.padArea.size / 2
    };
  },
  computed: {},
  methods: {
    // 触ったときの処理(マウス)
    mousestart(e) {
      this.isMousedown = true;
      this.padViewPosition.x = e.pageX - this.padArea.size / 2;
      this.padViewPosition.y = e.pageY - this.padArea.size / 2;
    },
    // 触ったときの処理(タップ)
    touchstart(e) {
      this.isMousedown = true;
      let touch = e.targetTouches[0];
      this.padViewPosition.x = touch.pageX - this.padArea.size / 2;
      this.padViewPosition.y = touch.pageY - this.padArea.size / 2;
      
    },
    // 動いている間の処理(マウス)
    mousemove(e) {
      if (this.isMousedown) {
        this.padMovePosition.x = e.pageX;
        this.padMovePosition.y = e.pageY;
      }
    },
    // 動いている間の処理(タップ)
    touchmove(e) {
      if (this.isMousedown) {
        if (e.targetTouches.length == 1) {
          let touch = e.targetTouches[0];
          this.padMovePosition.x = touch.pageX;
          this.padMovePosition.y = touch.pageY;
        }
      }
    },
    // 離したときの処理(マウス・タップ共通)
    touchend(e) {
      if (this.isMousedown) {
        this.isMousedown = false;
        this.padMovePosition.x = this.padViewPosition.x + this.padArea.size / 2;
        this.padMovePosition.y = this.padViewPosition.y + this.padArea.size / 2;
      }
    }
  }
};
</script>
```

# ざっくりした使い方
### ①呼び出し元の方でimportする

```javascript
import puni from "~/components/puni";
```

```javascript
export default {
  components: {
    puni,
  },
```

### ②設置

```javascript
<puni v-model="puniInfo"/>
```
シンプルなのでv-modelでコンポーネントからの通知を省略しているけど、特に画面の方からコンポーネント側に何かをバインドしているわけではない(コンポーネント側ではpropsの設定はしていない)ので、ちゃんと書くなら下記の方が適切かもしれない。

```javascript
<puni @input="puniInfo = $event"/>
```

### ③コンポーネントから通知された値を使って移動

```javascript
setInterval(() => {
          if (this.puniInfo && !(this.puniInfo.x == 0 && this.puniInfo.y == 0)) {
              this.position.x += this.puniInfo.x * 3 * (this.puniInfo.v <= 45 ? 0.5:1);
              this.position.y += this.puniInfo.y * 3 * (this.puniInfo.v <= 45 ? 0.5:1);
            }
        },
        16
      );
```
ヒヨコの座標がposition。
3は良い感じのスピード。
45は移動スピードの閾値(よくある、普通の移動とゆっくり移動の設定)。
setIntervalの16はFPS60だとそれくらいかなって。

# ざっくりした変更の仕方
円の大きさを変えたいとか色を変えたい！程度のことはdataのここをいじればいけるはず。

```javascript
      padHead: {
        size: 48,
        edgeSize: 5,
        color: "#aaa5",
        zIndex: 1100,
        style: {}
      },
      padRoot: {
        size: 32,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1050,
        style: {}
      },
      padArea: {
        size: 100,
        edgeSize: 3,
        color: "#aaa5",
        zIndex: 1010,
        style: {}
      }
```
size: 円の直径
edgeSize: borderのサイズ
color: 名前が適切じゃない、colorだけどborderに設定してる色の事
zIndex: そのまま
style: これはwatchで自動設定するやつなので変更してはいけない

# ざっくりした注意点
コンポーネント自体が

```css
position:absolute;
top:0;
left:0;
width:100%;
height:100%;
```
となっている。
つまり画面全体を覆いつつ(0,0)からの位置を前提にしている。
なのでposition:relativeと位置座標が設定されているタグの中に放り込むと位置がズレる。
あとz-indexを1000代に設定している。
(コンポーネントを変更せずにそのまま使いつつ)もしボタンを設定するなら、コンポーネントが画面全体を覆っている関係でz-indexが適用されるようにしつつz-index2000以上を使わないといけない。
(「z-index 効かない」とかでぐぐるとすぐに出てくる。大抵positionの設定が漏れてる。)

# 悩みどころ
コンポーネントが画面全体を覆う作りって微妙じゃないかなって…
タップの始点はpropsで受け取るようにした方が…と思いつつも機能的には始点から差分まで求めるのはコンポーネントで完結させたいよなぁ…

# おわりに
コンポーネントの主要なパラメータがまだベタ書きで変更にも弱い箇所あったりするので改良していく所存。
来年はこれ使ってクソアプリ作るぞ！