こんにちは。絵描き兼フロントの人の「ゆき」です。
先日Vue.jsのオンラインMeetupで「viteではじめるVue3 + TypeScript」という発表をしました。
Vue3の新しい開発環境であるViteの解説として簡単な「金魚が泳ぐデモ」を作ったのですが、LTではこのデモをほとんど見せる時間がなかったので、今日はQiitaで解説して元を取ろうと思います
作ったもの: https://yuneco.github.io/vite-kingyo/
リポジトリ: https://github.com/yuneco/vite-kingyo
リポジトリクローンして動かす時はyarn; yarn dev
だけでOKです。
この記事ではVue3の込み入った話やViteの話は書きませんが、Vue3のcomposition-apiでインタラクティブなアニメーションを作るためのポイントは適時挟んでいきます。composition-apiの動作する小さなサンプルアプリとして参考してもらえれば幸いです。
まずはCSSで金魚を作る
別に画像でも良いのですが、せっかくなのでちょっとCSS芸みたいなことをしてdiv
一つで金魚を作ってみたいと思います。
See the Pen BaKJdYa by Yuki (@Yuneco) on CodePen.
クラス名がKingyo
のdivを作って、styleで色を指定すると良い感じに金魚を表示します。
<div class="Kingyo" style="color:#f63;" ></div>
CSSはちょっと長いですが、border-width
を使って四角の一箇所だけに切り欠きを入れることで口や尾びれを表現している部分がわかればさほど難しくはないと思います。描画もさほど重くないはず。
関数コンポーネントで金魚のコンポーネントを作る
この金魚divをVueで動かしてアニメーションさせます。
アニメーションと言ってもCSS Transformで座標変えたりするだけです。Vueコンポーネントとして特別な状態管理は不要なので、関数コンポーネントにしましょう。受け取ったpropsを使ってstyle
をセットしているだけです。
import { h } from "vue";
type Props = {
x: number;
y: number;
angle: number;
color: string;
scale: number;
};
export const Fish = (props: Props) => {
const scale = props.scale || 1;
const style = `color: ${props.color};transform: translate(${props.x}px, ${
props.y
}px) rotate(${props.angle}deg) scale(${scale}, ${
scale * 0.8
}) rotate(${-45}deg);`;
return h("div", { class: "FishRoot", style });
};
金魚をたくさん配置する
金魚のコンポーネントができたので、これをたくさん並べるFishLayer
コンポーネントを作ります。
<template>
<!-- 指定の匹数の金魚を表示するレイヤーです -->
<div class="FishLayerRoot">
<Fish
v-for="fishProps in stageState.fishList"
:key="fishProps.id"
class="FishElement"
:x="fishProps.position.x"
:y="fishProps.position.y"
:angle="fishProps.angle"
:color="fishProps.color"
:scale="fishProps.scale"
/>
</div>
</template>
テンプレート部はシンプルですね。stageState.fishList
というところに格納されている金魚の情報をもとにFish(金魚)コンポーネントを表示しているだけです。
スクリプト部はちょっとややこしいので、⭐️印のポイントごとに分けて説明します。
/**
* 金魚レイヤーの状態を管理する型
*/
type StageState = {
fishList: FishModel[]; // ⭐️POINT1: 外部のFishModelクラスで金魚の状態を管理
};
export default defineComponent({
name: "FishLayer",
components: { Fish },
props: {
/** 最大金魚数:この数まで金魚が追加されます */
maxFish: { type: Number, default: 50 },
},
setup(props, ctx) {
// state: レイヤーの状態
const stageState = reactive<StageState>({
fishList: [],
});
// state: マウスの座標を状態として利用
const { mousePos: destination } = useMouse(); //⭐️POINT2: useMouseでポインター座標の管理を分離
// computed: 現在の金魚数
const fishCount = computed(() => stageState.fishList.length);
// method: 金魚の位置や速度を更新するメソッド
const updateFish = () => {
const destPoint = new Point(destination.x, destination.y);
stageState.fishList.forEach((fish) => fish.update(destPoint));
};
// method: 金魚を追加するメソッド
const addFish = () => {
stageState.fishList.push(new FishModel());
ctx.emit("count-changed", fishCount.value);
};
// method: 金魚を削除するメソッド
const removeFish = () => {
stageState.fishList.shift();
ctx.emit("count-changed", fishCount.value);
}
// 描画フレームごとに呼ばれる処理。金魚の状態を更新する
useAnimationFrame(() => { //⭐️POINT3: requestAnimationFrameの管理もuseで分離
updateFish();
if (fishCount.value < props.maxFish) {
addFish();
} else if (fishCount.value > props.maxFish) {
removeFish();
}
// trueを返すとunmountまでの間繰り返し呼ばれる
return true;
});
// クリック時の処理。金魚が逃げるようカーソル方向と逆の力を与える
useClick(() => {
stageState.fishList.forEach((fish) =>
fish.setForce(-1 - Math.random() * 4)
);
});
return {
stageState,
};
},
});
⭐️POINT1: 外部のFishModelクラスで金魚の状態を管理
Vueに限らず、インタラクティブなアニメーションをwebで作ろうとすると、良い感じの動きを試行錯誤するうちにすぐコードが謎の定数まみれの数式で埋まっていきます。これ自体は遊びで作る分には問題ないとは思うのですが、Vueのロジックと絡み合うと正直辛いです
好き嫌いはあると思いますが、この手のプログラムでは、金魚の位置や動きといった金魚に属するデータと振る舞い(メソッド)はオブジェクト指向的な考え方で独立した「金魚クラス」に押し込めるほうがクリーンです。
今回はFishModel
という金魚の状態と動きを定義したクラスをVueとは無関係な場所に作成して、この中で動きの試行錯誤を行うようにしています。
export class FishModel {
readonly id = instanseCount++;
/** 金魚の位置 */
position = randomPoint();
/** 金魚の向き */
angle = Math.random() * 360;
/** 金魚の速度ベクトル */
vector = new Point();
/** ゴールに向かう力の強さ */
force = DEFAULT_FORCE;
/** 金魚の色 */
color = randomFishColor();
/** 金魚のサイズ */
scale = randomScale();
/**
* 金魚の位置と速度を更新する
* @pparam destPoint 金魚が目指す点
*/
update(destPoint: Point) {
// 位置・座標を計算して更新
// ... 略 ...
}
/**
* 金魚が「目指す点」に向かう力を設定します。負値を指定すると反発して点から逃げます
* @param value
*/
setForce(value: number) {
this.force = value;
}
}
update
メソッドの中身等、実装はかなり汚いですが、逆にいろいろ試行錯誤したい部分をVueコンポーネントから切り離せていることがわかるかと思います。
⭐️POINT2: useMouseでポインター座標の管理を分離
useMouse
はcomposition-apiの解説で必ずと言って良いほど出てくるサンプルですが、実はインタラクティブなイベント処理を書くときには非常に有効なcomposition-apiの使い方です。
今回の例では、useMouse()
を呼び出すだけで何も考えずにリアクティブにマウスの座標扱えるようになります。
import { useMouse } from "../core/useMouse";
export default defineComponent({
setup(props, ctx) {
// state: マウスの座標を状態として利用
const { mousePos: destination } = useMouse();
// → destination.x と destination.y で常に現在のカーソル座標が利用できる
}
}
useMouse
の中身もちょっとみてみましょう。結構泥臭いコードが並んでいることがわかると思います。
import { onMounted, reactive, onUnmounted } from "vue";
export const useMouse = (targetDom?: HTMLElement) => {
const mousePos = reactive({
x: 0,
y: 0,
});
const onMove = (ev: PointerEvent): void => {
mousePos.x = ev.clientX;
mousePos.y = ev.clientY;
};
const onMoveTouch = (ev: TouchEvent): void => {
mousePos.x = ev.touches[0].clientX;
mousePos.y = ev.touches[0].clientY;
};
onMounted(() => {
const target = targetDom ?? document.body;
target.addEventListener("pointermove", onMove);
target.addEventListener("touchmove", onMoveTouch);
});
onUnmounted(() => {
const target = targetDom ?? document.body;
target.removeEventListener("pointermove", onMove);
target.removeEventListener("touchmove", onMoveTouch);
});
return {
mousePos,
};
};
今回のアプリでは、「PCではカーソル移動で」「モバイルではタッチ&スワイプで」金魚を誘導します。
この動作を一元的に扱うために、useMouse.ts
ではPointerEvent
とTouchEvent
の両方をListenしています。1
さらに、コンポーネントがunmountされたときにイベントリスナーを削除する処理も書いています。この手のイベントのListen&Removeは面倒な上にコンポーネントの可読性を下げるので、composition-apiで独立したファイルに追い出せるのはとても便利です。
⭐️POINT3: requestAnimationFrameの管理もuseで分離
インタラクティブなゲームやアニメーション表現をアニメーションライブラリなしで実装する場合、タイマーやwindow.requestAnimation
を多用することになります。この手のコードもコンポーネントを長くして可読性を下げるのでcomposition-apiで外に出してしまうのが吉です。
こんな感じで定義しておけば...
import { onMounted, onBeforeUnmount } from "vue";
/**
* requestAnimatinFrameの処理を登録します。
* @param onFire 処理。次のフレームでも継続して呼び出す場合、trueを返してください。
*/
export const useAnimationFrame = (onFire: () => boolean) => {
let isTerminated = false;
onMounted(() => {
const tick = () => {
requestAnimationFrame(() => {
if (isTerminated) {
return;
}
const shouldContinue = onFire();
if (shouldContinue) {
tick();
}
});
};
tick();
});
// アンマウント後に動作しないように停止フラグを立てる
onBeforeUnmount(() => {
isTerminated = true;
});
return {};
};
使う側はコンポーネントのマウント状態を意識せずに意味のあるロジックを書くことに集中できます。
import { useAnimationFrame } from "../core/useAnimationFrame";
export default defineComponent({
setup(props, ctx) {
useAnimationFrame(() => {
updateFish(); // 金魚の位置や速度を更新
if (fishCount.value < props.maxFish) {
addFish(); // 必要な金魚数が増えていたら追加投入
} else if (fishCount.value > props.maxFish) {
removeFish(); // 減っていたら金魚を削除
}
return true;
});
}
}
波紋を表現するWaveコンポーネントを作る
金魚だけだと寂しいので他にもいくつかコンポーネントを作ります。
それぞれ複雑さやロジックは異なっていますが、基本の考え方は一緒です
/src/components/Wave.vue
単一の波紋を表現するコンポーネントです。アニメーション完了後にend
イベントをemitする等、状態の管理が発生するので関数コンポーネントではない通常のコンポーネントにしています。
/src/components/WaveLayer.vue
複数の波紋を管理・表示するコンポーネントです。useMouse
でカーソル位置を、useClick
でクリック/タップイベントを、useTicker
でタイマーによるランダムなイベント発火を管理することで、波紋の追加削除をシンプルに書くことができます。
/src/core/WaveModel.ts
波紋の状態を管理するクラスです。モデル化するほど複雑なものではないのですが、FishModel
と同じ構造にするために独立したクラスにしています。
全てを組み合わせる
最後にStage
コンポーネントを作って全てのレイヤーを合成します。
<template>
<!-- ステージ全体のコンポーネントです。背景・金魚・波紋を合成します -->
<StageBg class="StageRoot">
<FishLayer :maxFish="maxFish" @count-changed="fishCountChanged" />
<WaveLayer />
</StageBg>
</template>
まとめ:Vue3のcomposition-apiはインタラクティブなアニメーション作りに(・∀・)イイ!!
この記事ではVue3のcomposition-apiを使って金魚を良い感じに泳がせるアプリを作ってみました。
Vue3のcomposition-apiは正直難しいと感じている方も多いと思います。
composition-apiの話はどうしても設計の良し悪しみたいな難しい議論になりやすいのですが、ゆるーく金魚を泳がせるアプリを作ってみることで「ちょっとイイかも...?」と思っていただけると嬉しいです。
-
本来は環境を判定してどちらか一方だけListenすべきです。両方無条件に扱っているのはただの手抜きです... ↩