はじめに
新しい技術スタックを試したくなり、Webブラウザで遊べるパーティーゲームを個人開発しました。普段はNext.jsを使うことが多いのですが、今回はReact Routerを採用してみたり、FirestoreやCloudinaryなど触ったことのないサービスを組み合わせて一つのアプリケーションを作ってみました。
TierPartyとは
ゲーマー界隈で人気な「Tier表」を作るパーティーゲームです。
- 出題されたお題に沿って、参加者をS〜Dのランクに振り分けてTier表を作る
- 5人程度のグループのレクリエーションやゲーム配信者がコラボ配信でわいわい遊ぶことを想定
技術スタック
| 領域 | 技術 | 選定理由 |
|---|---|---|
| ホスティング | Vercel | 無料で使えて、PRごとにPreview環境が自動で立ち上がる。別のアプリでも使用しているので使い慣れている。 |
| フロントエンド | TypeScript + React Router | 書き慣れたTypeScript。Next.jsからの変化としてReact Routerを採用 |
| データベース | Firestore | 初めて使ったNoSQL。ゲームデータはプレイ中だけ保持できればよいので開発の手軽さを重視 |
| 認証 | Firebase Authentication | ユーザー登録は不要。ホストとゲストの区別、リクエスト元の検証に使用 |
| ストレージ | Cloudinary | 無料枠が大きい。ユーザーのアイコン画像のアップロードに使用 |
実装でこだわったポイント
1. DnD Kitのドラッグ&ドロップUX
課題
- 複数の領域(S〜Dランク + 未ランク)へのドロップ機能と、領域内でのソート機能を両立させたかった
- 素直に実装すると、ドラッグ中のプレビュー位置とドロップ時の挿入位置に矛盾が起きる
解決策: 階層的な衝突検知
「コンテナ」はユーザーのアイコンをドロップできる領域のこと、「アイテム」はユーザーのアイコンのことを指しています。
やりたかったことは
- コンテナの内側であればそのコンテナ
- コンテナの外側であれば最後にカーソルが当たったコンテナ
という条件でIDを返す関数の実装です。
import { pointerWithin, rectIntersection, closestCenter, getFirstCollision } from '@dnd-kit/core'
// Tier表の状態(初期値はpropsで受け取る)
const [tierMap, setTierMap] = useState(initialTierMap)
const [unrankedPlayerIds, setUnrankedPlayerIds] = useState(initialUnrankedPlayerIds)
// 前回の有効なoverId(高速移動時のジャンプ防止用)
const lastOverIdRef = useRef<string | null>(null)
// 衝突検知する関数(argsの型は割愛)
const collisionDetectionStrategy= useCallback(
(args) => {
// まずポインタ位置で衝突判定、なければ矩形で判定
const pointerIntersections = pointerWithin(args)
const intersections =
pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args)
let overId = getFirstCollision(intersections, 'id')
if (overId != null) {
// コンテナIDの場合、その中のアイテムから最も近いものを選ぶ
const isContainer = overId === UNRANKED || RANKS.includes(overId as Rank)
if (isContainer) {
const containerItems = overId === UNRANKED ? unrankedPlayerIds : tierMap[overId as Rank]
if (containerItems.length > 0) {
const closestId = closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container) =>
container.id !== overId && containerItems.includes(container.id as string)
),
})[0]?.id
if (closestId) {
overId = closestId
}
}
}
lastOverIdRef.current = overId as string
return [{ id: overId }]
}
// 高速移動時のジャンプ防止
return lastOverIdRef.current ? [{ id: lastOverIdRef.current }] : []
},
[unrankedPlayerIds, tierMap]
)
<DndContext
collisionDetection={collisionDetectionStrategy}
...
ポイント1
Dndkitが用意してくれているpointerWithin, rectIntersectionの2つを使用し、フォールバックさせることで確実に衝突を検出します。
ポイント2
ドラッグ中のアイテムが別のアイテムに衝突しているときはアイテムのIDを返します。コンテナに衝突している場合は、closestCenterでその中の最も近いアイテムのIDを返します。ただしアイテムが無いコンテナの場合はコンテナのIDをそのまま返します。DndContextのcollisionDetectionに渡すことで衝突検知にこの関数を使ってくれます。
ポイント3
useRefで前回の有効な衝突先をキャッシュし、高速移動時のジャンプを防止しました。これがないとマウスを素早く動かしたときに変な動きをします。自動テストでカーソルを動かさせるとかなり速い動きをするのでテストのためにも必要でした。
操作の様子
参考
2. 招待URLのセキュリティ設計
ゲーム配信者が使うことを想定し、配信中に部屋のURLが映っても問題ないように設計しました。
招待URLは画面に表示しない
マウスで文字列をコピペするのではなく、ボタンクリックでクリップボードにコピーする方式をとりました。これにより、画面に招待URLを表示することなく共有できるようにしました。
tokenパラメータによる認証
ホストが部屋を作成時に招待用tokenを発行してlocalStorageに保持しつつ、firestoreにはハッシュ値のみ保送信するようにしました。念のためfirestoreには生のtokenを送信することを避けました。
招待URLには招待用tokenをクエリパラメータとして付与し、部屋の招待ハッシュ値と照合することで部外者の参加を防止します。
ゲスト参加時の露出は許容
ゲストは招待URLにアクセスすると招待URLがブラウザのURLバーに表示されます。このURLは参加する際に名前やアイコンを設定する画面なので、一定時間URLが晒されることにはなります。ただし、この画面に滞在する時間は短く、部屋IDとtokenの組み合わせは手入力が困難なため許容することにしました。
感想
dnd kitをAIに書かせるのが大変だった
dnd kitはメジャーなライブラリだけど、割とよくありそうな今回のユースケースでもrefやらstateやらを使って衝突の状態管理しなきゃいけなくて、進めながら「これでいいのか...?」と不安になってました。このくらいならライブラリ側の機能として提供されているのではという疑問を持ってたんですが、公式のstorybookで公開されているコンポーネントの実装を見ると、同じように衝突判定のロジックを書いていました。
このコンポーネントをclaude codeに読み込ませて実装させたのですが、なかなかうまくいかず...。なにせマウスでドラッグアンドドロップをして人間が気持ちよく操作できるように実装する、ということはAIに指示しづらいです。
対処としてまずは自動テストを書いてからclaude codeでロジックやらコンポーネントの実装をするという手順を撮りました。自然言語として指示するよりも、自動テストコードとして意図を伝えるほうが明確に指示できました。
公開してみたもののユーザーがいない
複数人必要でハードルが高いのと、使ってもらえるように宣伝しているわけではないので当然なのですが...笑
励みになるのでよかったら遊んでみてくださいmm
アクセス数はGoogleAnalyticsでチェックするようにしています。


