[WEB構築]謎解きSPACEのここが非常識だったよ!
こんにちは!
6/2にリリースした謎解きゲーム「SPACE」のウェブ構築を担当した自称エンジニア、Rihaelです。
(エセエンジニアなだけに)自分の中では物凄く頑張って構築したのですがそれに対してあまり非常識さが伝わってない気がしてちょっと悔しいので、何を頑張ったのかをnoteでサクっと共有したいと思います(自己満)。
まずは自己紹介...
●WEBの経歴
2019.12 ~ ポッドキャスト作りたいなぁと思う
2020.1 ~ Macbook購入、マークアップ言語とJavaScriptの独学を始める
2020.7 ~ 就活中に何故か流れでフリーランスになる
以上
★SPACE制作の流れ
2021.1 ~ webの謎解きサイト作りたいという少年と出会う
2021.2 ~ ライブラリを使って構築するやり方を教えるというスタンスで協力し始める
2021.3 ~ ライブラリではなく全部スクラッチで作らないと無理っぽい
2021.4 ~ まじか!
2021.6.2 リリース(Done is better than perfect🌟🍺)
★技術
Nuxt.js (Vue.js)
Three.js
★3Dモデル作成ソフト
Blender(少年手がける)
ちなみに高校のとき苦手すぎて2年時から数学を選択しなかった超文系人間(日本語も苦手)な私は、Three.jsは存在だけ知っていて使用することを断固拒否し代わりにA-FrameというwebGLのライブラリを推していたが、途中で必須と分かり3月末からアドリブ(?)でコーディングしました(ここも非常識ポイントでもある)。それとNuxt.jsも勉強中でした。
回答欄と照合、オリジナルキャレット、暗号化
当初ライブラリ「vue-fake-input」というものを採用する予定だったが、オリジナルのキャレット(●丸いやつ)だったり下線の色の変化だったり不正解時のエフェクトだったりの実装のために、結局スクラッチで作ることに。
多分、構築で一番躓きました。以下サクっと紹介。
①キャレット作成のためinputをdivで囲っていて、それを文字数分並べているので、文字が入力されたらそのinputの親要素の隣の要素の子要素をフォーカスするという鬼めんどい点
<div class="input-wrapper" v-for="n in length" :key="n">
<input
:id="'character_' + n"
class="character"
type="text"
maxlength="1"
autocomplete="off"
v-model="characters[n - 1]"
@focus="isFocusOn(n, $event)"
@keydown="moveOnTo(n, $event)"
@input="moveOnNext(n, $event)"
@blur="isFocusOff"
/>
</div>
②v-modelというvue.jsに用意されている糖衣構文というものを使って文字列をvueのdataに溜め、バックスペースで入力文字が消去されたらv-modelに溜めてある文字もちゃんと消去しないといけない点
③既に文字が入力されているところに上から新たな文字を入力するとその文字で上書きされると言う仕様(私が勝手に追加した(後のバーチャルキーボードのために))のために keydown と input のイベント二刀流という点
moveOnTo(n, event) {
//keydown
var elem = event.target;
var value = event.target.value;
var parent = event.target.parentNode;
var nextElem = parent.nextElementSibling?.firstElementChild;
var preElem = parent.previousElementSibling?.firstElementChild;
if (event.keyCode == 39) {
//right
if (nextElem == null) {
return;
}
nextElem.focus();
return;
}
if (event.keyCode == 37) {
//left
if (preElem == null) {
return;
}
preElem.focus();
return;
}
if (event.keyCode == 8) {
//backspace
if (elem.value == "") {
if (preElem == null) {
return;
}
preElem.value = "";
this.characters[n - 2] = "";
preElem.focus();
preElem.parentNode.classList.remove("borderBottom");
return;
}
parent.classList.remove("borderBottom");
elem.value = "";
this.characters[n - 1] = "";
return;
}
elem.value = "";
this.characters[n - 1] = "";
},
moveOnNext(n, event) {
var elem = event.target;
var value = event.target.value;
var parent = event.target.parentNode;
parent.classList.add("borderBottom");
var nextElem = parent.nextElementSibling?.firstElementChild;
var firstElem = document.getElementById('character_1');
if (nextElem == null) {
firstElem.focus();
return;
}
var characters = this.characters;
characters[n - 1] = value;
nextElem.focus();
},
④暗号化して照合すると言う点
hashing () {
sha256(this.connected).then(hash => {
this.hashed = hash;
return;
});
async function sha256(text){
const uint8 = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', uint8);
return Array.from(new Uint8Array(digest)).map(v => v.toString(16).padStart(2,'0')).join('')
}
},
余談 : リリース後に、同じ文字列を2回連続入力すると不正解時のエフェクトが出ないよ!と言う報告をいただきましたがそれは
this.hashed = "";
を書くのを忘れていた。
なんだか非常識な点というか頑張った点というか全てのコードを解説する勢いになっちゃったので(上記のコードはほんの一部分)、以下8%くらいまで厳選して解説していこうと思います...。
バーチャルキーボード
スマホ、タブレット時のみ、出現するキーボードです。
なぜ端末のキーボードを使わなかったか、それはinputからinputへ移動したときに日本語のキーボードに戻ってしまうから。
バーチャルキーボードを作るとなると、あの鬼だるいイベント処理また書き直しなの?!と思い、もう覚えてないけどどうにかして遠隔でイベントを発生させて既に書いたコードを使い回せるようにしました。
currentElem.dispatchEvent(e);
canvasで3Dモデル読み込み描画、パーツにアクセス
初めてのcanvasに初めてのThree.js!
殺す気かな?
こんなに書くとは思わなかった!(TOPページ)
初期設定やトラックボールコントロールは少年がセットしてくれました。
あと軽量化と画面のリサイズなども。
めっちゃ勉強になった。
フォーカスしたパーツに引力を発生させるためのコードはこんな感じ(TOPページのあれです)
async putGravity() {
const sleep = (time) => {
return new Promise((resolve) => setTimeout(resolve, time));
}
for( let i = 1; i < 1000; i++ ) {
await sleep(1);
if( this.checkMousedown == true || this.rendering == false) { break; }
this.camera.position.x += ( this.targetPosition.x - this.camera.position.x ) * 0.01;
this.camera.position.y += ( this.targetPosition.y - this.camera.position.y ) * 0.01;
this.camera.position.z += ( this.targetPosition.z - this.camera.position.z ) * 0.01;
}
},
x,y,zとか数学嫌いすぎて拷問でした。
このコードを書くまでにクオータニオンとかオイラーなんちゃらまで調べちゃって詰みまくって精神的に無駄だった....。
タッチイベントでRaycaster、ページ遷移
Raycasterという、光線を発射して要素との交差を感知するという、最初聞いたときは書ける自信なさすぎて死ぬかと思いました。
Three.jsの公式ページ
参考にしたコード
大体は英語の記事やQAを参考にしたので、英語ある程度読めて本当に助かった。
未クリアかつアクセス可能なパーツ選択では、実はこの後ろにクリア後の黒いパーツが非表示にされてあり、Raycasterは非表示のものにも交差するというのを偶然発見したのでそれを利用しています。それ以外のパーツはRaycasterに交差するリストの中に入れてないので反応しないという感じ。
Rayの発射タイミングについては2つあり、
マウスが動いている間、常に中央に発射し続けているものと、
canvasを他の要素で囲んでそこにイベントを設定することで可能にしています。
しかしiosだとmouseイベントとpointerイベントが重なって発火してしまってるのでそこを今後直せたらいいな..(遠い目)
<section
id="page_map"
class="max"
@mousedown="onMousedown"
@mousemove="onMousemove"
@mouseup.prevent="onMouseup($event)"
@pointerdown="onMousedown"
@pointermove="onMousemove"
@pointerup="onMouseup($event)">
<canvas
id="map__canvas"
class="map__canvas"
ref="canvas"
></canvas>
</section>
非同期ページ遷移とBGM再生
SPACEは非同期遷移を採用しているので、実はTOPページから問題ページに飛んでいるのではなく、JavascriptでTOPページを抹消して問題ページを描画し直しています。
暗くなったり白くなったりのフェードをシームレスに綺麗にしたかったのと、BGMの自動再生を可能にするためにそうしました。
そのおかげで、再生の状態管理がとても楽になりました。
Nuxt.jsの layout > default.vue に、BGMのコンポーネントを置くことで、ページ遷移時にもインスタンスが消去されることがなく(何故なら<nuxt />内が書き換えられているから)コンポーネントのdataをそのまま残しておく事ができ、TOPに戻った時に自動でBGMを再生することができます。
あとは問題ページでBGMを非表示にするだけ。
data() {
return {
pointerEvents: 'none',
started: false,
playOn: false,
muted: true,
}
},
しかしながら非同期遷移が故に、canvasのデータが蓄積されてメモリリークみたいな事が起きているので、今後改善していきたいですね...(遠い目)
webストレージで進捗の管理
当初、VuexというVue.jsの状態管理のライブラリ(?)を使えばできるかな〜と思っていたが知識不足で結局それは不可能と知り、Web Strageを採用しました。
クッキーとweb strageの違い
さすがにweb strageを操作するコードまで書く気力がなかったので、ライブラリを使用しました。
vuexとwebstrageを連携してくれる超便利でドンピシャなものが見つかってよかった。
参考記事
解答の文字列が次の問題ページのスラッグとなる、すなわちコード上に一切解答の文字列を書いてはいけない(一番鬼)
nuxt.jsで非同期遷移(問題ページに移動)するときに、
this.$router.push('/ここにスラッグ');
と書かなければならないのですが、
スラッグのところに答えを書くわけにはいかず(ソースコード見たら全てバレてしまう)ここはかなり躓きました。
2ヶ月間別の構築をしながら脳の片隅でずっと考え続け、最後の最後に何度も転びながら構築し
奮闘の末、無事、バックエンド技術を使わずとも、文字列をコードに書かずともパーツをクリックするときちんとその問題ページへ飛ぶという謎を作り上げました!おめでとー🍻🌟
機密情報なのでここには書けませんがね.........................................。
終わり
ここでざっくり解説した内容は実は7割くらいであと3割くらい紹介しきれてない頑張りポイントがありますが、ちょっと疲れてしまったのでこの辺で終わりにしようと思います!
最後まで読んでくださりありがとうございました!