Composableの中でComponentを返せたらと考えていたところ、まさにそのアプローチがあった!と感動したので、その方法を採用してUnityのWebGLを返すComposableを作成してみました。
環境
VueUseの利用
VueUseのcreateInjectionStateを使いたいため、まずは使い方を見ておきたいと思います。
こちらは、provideとinjectをcomposableの仕組みで使いやすくしたようなものになります。createInjectionStateを呼び出すと、provideとinjectをラップした2つのglobal state関数が返されます。そして、createInjectionStateを呼び出す際にprovideするstateを引数として渡します。
例えば、使用例は以下のようになります。今回はあまり意味がないのですが、配列に格納した文字列をカットして返すstateを作成してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import { computed, ref, Ref } from "vue-demi"; import { createInjectionState } from "@vueuse/shared"; const [useProvideContentStore, useContentStore] = createInjectionState( (initialValue: string, limit: number = 80) => { // state const contents = ref<string[]>([]); contents.value.push(initialValue); // getters const summaries = computed(() => contents.value.map((c) => c.length <= limit ? c : c.substring(0, limit) + "...", ), ); function addContent(body: string) { contents.value.push(body); } return { contents: readonly(contents), addContent, summaries }; }, ); export { useProvideContentStore, useContentStore }; |
この使い方は以下のようになります。まず、provide側。
1 2 3 4 5 6 7 8 9 |
<script lang="ts" setup> useProvideContentStore("テスト文字列です、テスト文字列です", 20); </script> <template> <div> <ContentSummaryChild /> </div> </template> |
続いてinject側。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
<script lang="ts" setup> const { summaries, addContent } = useContentStore(); const append = () => { addContent( "テスト文字列です" + [Number(Math.random() * 10)].reduce( (c, i) => i + c, "これはテストなのです", ), ); }; </script> <template> <div> <button type="button" @click="append">文字列追加</button> <ul v-for="c in summaries"> <li>{{ c }}</li> </ul> </div> </template> |
実行すると以下のようになります。
VueUse: https://vueuse.org/
useUnityを書いてみる
useUnityでは以下のような方針で書いてみたいと思います。
- ComponentとComponent内の外部スクリプトのロード完了状態(UnityInstance)を統合的に利用可能にする
- provideとinjectでUnityInstanceにアクセスできるようにする
- provideしたインスタンスで透過的にinjectができる
- 画面のリサイズでcanvasをリサイズする
全体像は以下になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
import { useScriptTag, useResizeObserver } from "@vueuse/core"; import defu from "defu"; import { type InjectionKey, inject, provide } from "vue-demi"; /** * This implementatio get hints from vueuse * @see https://vueuse.org/createInjectionState * License: https://github.com/vueuse/vueuse/blob/main/LICENSE */ function createInjectionState<Arguments extends Array<any>, Return>( composable: (...args: Arguments) => Return, ): readonly [ useProvidingState: (...args: Arguments) => void, useInjectedState: () => Return | undefined, ] { const key: string | InjectionKey<Return> = Symbol("UnityInjectionState"); const useProvidingState = (...args: Arguments) => { provide(key, composable(...args)); }; // If called from same vm, jnject with self const useInjectedState = () => injectWithSelf(key); return [useProvidingState, useInjectedState]; } // Define composable refs and UnityComponent const [useProvideUnityStore, useUnityStore] = createInjectionState( (initialValue?: any, callback?: (unityInstance: any) => void) => { const unity = ref<any>(initialValue); const loaded = ref(false); const loading = ref(false); const error = ref<Error>(); const containerId = useState("unityContainerId", () => Number(Math.random().toString().slice(3)).toString(36), ); function SendMessage(gameObject: string, method: string, param?: any) { if (!loaded.value) { return; } if (param) { unity.value?.SendMessage(gameObject, method, param); } else { unity.value?.SendMessage(gameObject, method); } } function done(unityInstance: any) { if (loaded.value) { return; } unity.value = unityInstance; loaded.value = true; loading.value = false; if (callback) { callback(unityInstance); } } // // Define UnityComponent which is available in sfc's template. // const UnityComponent = defineComponent({ name: "UnityContainer", props: { width: { type: String, required: true, }, height: { type: String, required: true, }, unityLoader: { type: String, required: true, }, onProgress: { type: Function, default: (progress: number) => { console.log(`unity loading... ${progress * 100}%`); }, }, config: { type: Object, default: null, }, resizable: { type: Boolean, default: true, }, }, emits: ["loading", "loaded", "error"], setup(props, context) { const unityLoaderLoaded = ref(false); const canvasWidth = ref(props.width); const canvasHeight = ref(props.height); const container = ref<HTMLElement>(); const canvas = ref<HTMLElement>(); const config = defu(props.config, { dataUrl: "Build.data", frameworkUrl: "Build.framework.js", codeUrl: "Build.wasm", companyName: "DefaultCompany", productName: "UnityApp", productVersion: "0.1", showBanner: false, }); function instantiate() { if ( typeof window.createUnityInstance === "undefined" && unityLoaderLoaded.value ) { const _error = "The UnityLoader was not defined, please add the script tag "; error.value = new Error(_error); context.emit("error", _error); return; } if (loading.value) { return; } context.emit("loading"); loading.value = true; const { $logger } = useNuxtApp(); const canvas = document.querySelector( `#unity-canvas-${containerId.value}`, ); window .createUnityInstance(canvas, config, props.onProgress) .then((unityInstance: any) => { done(unityInstance); context.emit("loaded"); }) .catch((message: any) => { $logger.error(message); error.value = new Error(message); context.emit("error"); }); } const { load } = useScriptTag( props.unityLoader, () => { unityLoaderLoaded.value = true; instantiate(); }, { manual: true }, ); onBeforeMount(() => { load(); }); onMounted(() => { useResizeObserver(container, (entries) => { if (!props.resizable) { return; } const entry = entries[0]; const { clientWidth, clientHeight } = entry.target; canvasWidth.value = clientWidth + "px"; canvasHeight.value = clientHeight + "px"; }); }); return { canvasWidth, canvasHeight, container, canvas }; }, render() { return h( "div", { id: `unity-container-${containerId.value}`, class: "unity-container", ref: "container", }, [ h( "canvas", { id: `unity-canvas-${containerId.value}`, class: "unity-canvas", style: { width: this.canvasWidth, height: this.canvasHeight, display: "block", }, ref: "canvas", }, [], ), h("div", { class: "unity-extra-content" }, this.$slots.default?.()), ], ); }, }); return { unity, loaded, SendMessage, containerId, UnityComponent }; }, ); export { useProvideUnityStore, useUnityStore }; export function useUnityStoreOrThrow() { const store = useUnityStore(); if (store == null) throw new Error( "Please call `useProvideCounterStore` on the appropriate parent component", ); return store; } // Uses same component provide as its own injections // Due to changes in https://github.com/vuejs/vue-next/pull/2424 // @see https://github.com/logaretm/vee-validate // License: https://github.com/logaretm/vee-validate/blob/main/LICENSE export function injectWithSelf<T>( symbol: InjectionKey<T>, def: T | undefined = undefined, ): T | undefined { const vm = getCurrentInstance() as any; return vm?.provides[symbol as any] || inject(symbol, def); } |
続いて利用側です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
<script lang="ts" setup> useProvideUnityStore(); const { UnityComponent, loaded } = useUnityStoreOrThrow(); </script> <template> <div class="container"> <UnityComponent width="1000px" height="600px" unity-loader="/unity/Build.loader.js" :config="{ dataUrl: '/unity/Build.data', frameworkUrl: '/unity/Build.framework.js', codeUrl: '/unity/Build.wasm', }" :resizable="false" > {{ loaded }} </UnityComponent> </div> </template> <style lang="scss"> html, body { width: 100%; height: 100vh; margin: 0; padding: 0; overflow: hidden; } #__nuxt, .container, .unity-container { width: 100%; height: 100%; } </style> |
以下補足説明です。
vueuseのcreateInjectionStateについて、ここではinjectの呼び出し部分を少し修正しています(ライセンスMIT:https://github.com/vueuse/vueuse/blob/main/LICENSE)。目的はprovideを呼び出したコンポーネント内でinjectしたstateやComponentを使うためです。ここではUnityComponentを指します。
一番下のinjectWithSelfはvee-validateの実装で使われているものです。(ライセンスMIT:https://github.com/logaretm/vee-validate/blob/main/LICENSE)。provideのコンポーネント内でもinjectを透過的に使用できるようにするためです。
今回は、以下の部分がなるほど!と思ったところです。UnityComponentを定義するところになります。
1 2 3 4 |
const UnityComponent = defineComponent({ name: "UnityContainer", // ...省略 }) |
defineComponentでコンポーネントを定義しています。template部分は、今回はrender呼び出しを使う方法で試してみました。このUnityComponentは、templateの中で通常のComponentと同じように呼び出せます。
Unity WebGLのcanvasをリサイズ可能にするためにuseResizeObserverでcanvasのラッパータグのリサイズをウォッチします。変更があればcanvasのwidthとheightを書き換えます。
loadedでUnityLoaderからのUnityInstance初期化の完了を受け取れます。
ComponentとComponentのロード完了(UnityInstance)状態(loaded)を統合的に扱えることで、分かりやすい実装にできるのではと思います。
参考リンク
- https://engineering.linecorp.com/ja/blog/line-securities-frontend-3/
- https://zenn.dev/crayfisher_zari/articles/7946414921fe42
2023.4.8
npmモジュール化しました。
コメント