背景
- 友人たちとTRPGをやるようになった
- セッションはSkype越しにやっており、皆が個別のダイスツールを使っているので、虚偽の報告が可能(しないとは思ってるけど、まぁ、可能)。
- リアルタイムでダイスロールを共有するWEBアプリを作ってみた
- TRPGをやる回数が増えた
- ダイスロール共有だけじゃ機能が足りないと言われた
- ここまで来たら他のツール使ったほうがいいんじゃね?
- いや、ここは勉強!自分で実装してみよう!ReactもReduxもwebsocketも知りたいし!
※ちなみに大体4人くらいでやってるけど一人を除いて全員TRPG初心者ですw
TL;DR
- github(振り返ってみるとすごいコード汚い…)
- サービスのURL
ざっと機能を並べると...
- 部屋作成と管理(部屋のホスト変えたり、メンバーkickしたり) - 味方と敵キャラ作成 - ダイスロール(UIからできる & チャットから特定文字力入力でダイスロール) - チャット (プレイヤーとして話すことも、キャラとして話したりすることも、プライベートメッセージ送ったりも可能) - キャラを置いて動かせるボード作成 (ボードそのもの移動したり、キャラを動かしたり、ズームしたりなども可能) - 情報を部分的に隠蔽(キャラの名前やステータス、ボードの特定部分など) - メモ作成 - 音楽とか動画を流したいときのためにyoutubeの動画も共有できる - スマホ対応(使う人が自分たちしかいないのになんでしたのかわからないw) - 多言語対応 en, jp(まじでなんでいれたかわからないw)[画像]ロビー (部屋を作る or 部屋のID入力で参加する)
![1588967975101.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317253/fd703b2f-4fb8-7612-288e-f154aba75d29.jpeg)[画像]チャット(ダイスロールの結果や画像なども送れる)
![1588970030040.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317253/ab86ed8f-4156-d46e-ac37-77d928e1cbab.jpeg)[画像]ボードでキャラを動かす
![1588970385562.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317253/69787118-80e8-8c7b-a035-2bc1e6512ddc.gif)使用技術
上の部分だけだとただの宣伝だけになっちゃうので技術的な話を…
メインで使用してる技術は以下
- フロント:
React + Redux + socket.io
- バック:
Express + socket.io
リアルタイムで情報共有するにはweb socketを使ってて、簡単にコードするためのライブラリがsocket.io
.
開発で苦労した箇所・難しかった箇所・後悔した箇所
- データ設計
- データの管理方法
- TypeScript使ってなかった…
- 部屋の人とのみ情報共有
- あとから部屋に入ってきた人に情報共有
- 退室検知
- ボードとキャラを動かし方
- スマホでのボードとキャラの動かし方
- 四角の角を引っ張って大きさを変える
- 多言語対応(libraryの存在を知らなかったから…)
- ドメイン取得とHTTPS化
データ設計
まぁ、ここは当然でしょうね。どんなプロジェクトもここは苦労します。だけど、最初から最後までずっとデータ構造やその表現方法を悩んでたのでここに載せました。機能を増やすときに「こっちのほうがいいのでは?」みたいな感じで途中でがらりとデータ構造が変わることもありましたし、それでコードの手直しがありえんほど増えました。個人で作ってるプロジェクトだからここまで手直しできたんだなぁっと思いました。
結果として大きく分類するとデータの種類としては
- 部屋
- ユーザー
- キャラ
- ボード
- チャット
- メモ
になりました。
あとはそれぞれのデータの紐付け用のデータくらい(ユーザーとキャラの紐付け
など)
データの管理方法
部屋の情報や状況(部屋の有無、部屋の中にいるユーザーやキャラ)などをどう管理するかも迷いました。DBやらファイルやら色々迷いましたけど、以下のようにしました。
- 部屋: バック
- その他全部のデータ: フロント
なんでフロントでデータ管理するかというと、キャラとかボードとか画像ファイルを多用するアプリで全ての部屋の情報を全部サーバー側で保管してたらサーバーのスペックを気にしないといけなくなるなぁって思ったからです(お金かけたくなかった)。
そして、フロント側でやれば自分のいる部屋の情報だけ持っておけばいいから楽かなと思ったからです。
結果として、フロントが最終的なデータを持っていて、バック側はフロントからもらったデータを同じ部屋の人に伝えるだけのものになりました。
ちなみに…これだとフロントでなにか起きたときに、データにずれが生じる可能性があって問題になるかもしれないというのは認識はしているのですが、TRPG仲間(もはやProduct Owner?)は少しくらいなら構わないって言ってくれたので保留にしてます。実際やってて問題になったことはないですね。
TypeScript使ってなかった…
データをコードに落とし込むときは基本的にデータ設計したものをコード化すればいいだけだけど…こういうとこでTypeScript使わなかったことを後悔してます。
いつも「このデータの中ってどうなってたっけ?」ってなりましたし、変な値を入れてよく壊れてました。これを作り始めたタイミングでTypeScriptは手を付けてなかったですが、業務でTypeScriptを使ってから、「あ、TypeScript偉大だわ、使えばよかったな」って思いましたねw。
そもそもコードの中に以下のようなコメント書いてる時点でおかしいというか…
const users = [
// {
// id: '123457',
// name: 'John Doe',
// host: false,
// }
]
TypeScript使ってれば...こう書けた上にコード書いてるときに事前に変な値入れたときとかわかったのに...
type User = {
id: string
name: string
host: boolean
}
const users: User[] = []
部屋の人とのみ情報共有
部屋を作ってその中の人達とリアルタイムでやり取りするなので、誰が自分の部屋にいるかを知る必要がありますね。他の部屋の人にデータが飛んでいったらワケワカランですからね(急にいろんなデータが増えてそれはそれで面白いだろうけどw)。
これはsocket.io
にroom
という概念があったので、それを使えば簡単でした(探すのが大変だったw)。
// to(roomId)で部屋の人だけに情報を送信
socket.on('event', (roomId, payload) => {
socket.broadcast.to(roomId).emit('event', payload)
});
他のsocket.ioのapiについては以下のサイトを参照
https://socket.io/docs/server-api/
あとから部屋に入ってきた人に情報共有
これは前のやつと似てますが、データを送信したときのタイミングで部屋に入ってるユーザーにはデータは渡るけど、あとから部屋に入ってきたユーザーには渡らないという問題がありました。この問題にぶち当たったときに、「あー、やっぱりバック側でデータを保管しておくほうが正解だったのかな?」って思い色々葛藤しました。
結果的に、新しいユーザーが部屋に入ったタイミングで既に部屋の中にいたユーザーにそれを知らせ、それをトリガーにユーザーたちは自分の持っているデータを新しく入ったユーザーに伝えるという処理を加えることでカバーしました。(これが一番いい方法だったかは未だにわかりませんが...)
退室検知
ユーザーが部屋から出るときに、そのユーザーが持ってたデータをすべて消す仕様にしました。
退室ボタンを用意したので、それで退室してくれると簡単にできましたが。ブラウザを急に閉じられたときにどうすればよいかわかってませんでした。
結果的にwindowのunload
イベントでハンドリングすることにしました。
また、誤って閉じちゃったり戻るボタンを押しちゃったりする可能性があったので、windowのbeforeunload
イベントをハンドリングすることで実際に閉じちゃう前にconfirm
を開くようにしました。
class Room extends Component {
/* ...省略... */
onBeforeUnload (e){
e.preventDefault()
e.returnValue = '' // for Chrome
}
onUnload (e){
socket.emit('leave', roomId, payload)
socket.disconnect()
}
componentDidMount (){
window.addEventListener('beforeunload', this.onBeforeUnload) // ブラウザ閉じる前にconfirmをだす
window.addEventListener('unload', this.onUnload) // ブラウザ閉じるときに部屋の人に退室したことを伝える
}
/* ...省略... */
}
ボードとキャラの動かし方
ただのフロントの実装の話ですね。
マウスを使って自由にDOMを動かす操作は色んな所で使われています。
多分ここらへんが一番時間かかったと思います。
[画像]イメージとしてはこんな感じです。
![1588970385562.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317253/69787118-80e8-8c7b-a035-2bc1e6512ddc.gif)コードの書き方としては以下のサイトが非常に参考になりました。
https://javascript.info/mouse-drag-and-drop
要約すると...
- その要素の位置を計算するときは要素上のmouseの位置を計算に入れろ
- その要素の
position
はpage上のmouseの位置ではなく、page上のmouseの位置からその要素の左上の点からみたmouseの位置の相対値を引いたもの(日本語が難しいね)
- その要素の
- 動かし始めるイベント
mousedown
は動かしたい要素そのものにつけろ - 動かしている間のイベント
mousemove
と動かし終わったときのイベントmouseup
はwindowとかdocumentにつけろ- mouseを早く動かすとmouseが要素上から離れてしまうことが多いため、要素そのものに乗せると
mousemove
tomouseup
イベントをハンドリングできなくなる
- mouseを早く動かすとmouseが要素上から離れてしまうことが多いため、要素そのものに乗せると
また、このプロジェクトに関しては、ボードとキャラ(両方ともmouseで動かせる)が重なっているため、両方一緒に動過内容に注意しないといけませんでした。ただstopPropagation()
を意識したって話ですけどね。
スマホでのボードとキャラの動かし方
計算方法などは上と一緒ですね。
イベントがmouse
系のものからtouch
系に変わるだけですし。
ただ、一番つらかったのが、
- androidだと下にドラッグすると
pull to refresh
の動作が走ってしまい、touch動作が邪魔されること - iPhoneだと上下にドラッグしたときに
rubberband scroll
が邪魔してうまくtouch動作が邪魔されること(rubberband scroll動画)
この2点でしたね。
androidのpull to refresh
を無効化するのは実は簡単で、ただcssを設定するだけですね。
いろんな記事で紹介されてますが、以下のcssだけで無効化できます。
/* androidのpull to refresh機能の無効化 */
body {
overscroll-behavior: none;
}
iPhoneのrubberband scroll
はJavaScriptで無効化できました。
window.addEventListener('touchmove', function(e){
e.preventDefault()
}, {passive: false} )
// reactのroot要素にもつけてあげないと無効化できませんでした
document.querySelector('#root').addEventListener('touchmove', function (e){
e.stopPropagation()
})
四角の角を引っ張って大きさを変える
ボード上の特定部分を隠すためのブロックを設定するために必要な機能でした。
[画像]イメージとしてはこんな感じです。
![1589020464479.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317253/dc0a6840-88de-79e7-d793-6e0b0c6706f5.gif)多言語対応
使う人が友達しかいないのに多言語対応したのは自分でも意味がわからないけど、やってみました。
これに関してはi18nライブラリの存在知らなくてかつ「なんか自分でできそう」みたいな馬鹿な考えでやっちゃいました…
結局やったこととしては、文言ごとに言語コードをkey、文言をvalueniしたobjectを用意して、それに対して現在設定されている言語コードを突っ込むだけみたいな感じにしてました。
コードだとこんな感じ
// redux state
const globalState = {
// ...otherStates
lang: 'JA'
}
// lobby.i18n.js
export const text = {
EN: 'English Text',
JA: '日本語テキスト'
}
// lobby.jsx
/* 他のimport省略 */
import { text } from './lobby.i18n.js'
const mapStateToProps = (state) => {
return { lang: state.lang}
}
class Lobby extends Component {
/* 省略 */
render() {
{/* 言語変わるとここも変わる */}
<div>{text[lang]}</div>
}
}
export default connect(mapStateToProps)(Lobby);
業務でi18nのライブラリを使いましたが、こちらでもi18nするんだったらそうすればよかったと後悔してますw
ドメイン取得とHTTPS化
ここからは実際にネット公開するとこの話ですね。
今ではwebアプリを公開するためのサービスがいっぱいありますが、私は自分でサーバーの設定をしたり、ドメイン取得して設定したり、https化するための設定したりする方法や流れを知りたかったので、あえて使いませんでした。
とかいいつつサーバーはVPSですし、ドメイン購入はそういうサービスたくさんありますし、SSLの証明書はletsencrypt使ったりで、だいぶ楽してる方なんでしょうね。
ドメインの購入はそういうサイトの方にいっぱいやり方書いてあると思います(テキトー)。
ドメインを購入したら向き先を自分のサーバーのIPにすることもしないといけないですね。
letsencryptの使い方はこの記事を参考にしました
https://qiita.com/shojimotio/items/4fa82b21390e8a6d8446
web serverはnginxを使ったので証明書の設定方法は以下を参考にしました
http://nginx.org/en/docs/http/configuring_https_servers.html
作ってみた感想
やはり本だけ読むのと実際に触ってみるのとでは、頭に残る量が違いますね。
あとやはり開発は楽しい!特に作ったものを試してくれてすぐにフィードバックをくれる人がいるとなおさらいいですね。
使っている中でここ直したいなぁーとか機能追加したいなーとかいっぱい出てきてますが、まぁまぁ使えるっぽいものができたので自分的には満足です。