オシッピー

https://github.com/KOU050223/ossippee

React

Unity

Node.js

Firebase

Flutter

飲み会に行った人の話を追体験するゲーム 開放感・焦りを表現したい

ウオミー

iseebi

推しアイデア

飲み会という普遍的なイベントから、「トイレを我慢する」という緊張感と面白味を持ったストーリーにしたこと。

作った背景

今回のテーマが「4コマ漫画の起承転結」だったので、インパクトのあるストーリーにしたかった。

推し技術

4つの技術を使ったこと。

プロジェクト詳細

オシッピー

初めに... デプロイ先でfirebase連携があまりうまく言ってないです

目次

  • 作った経緯・アイデア
  • 起承転結
  • 技術的なところ [Unity] [LineAPI] [Flutter] [React]
  • 全体の統合

デモ動画

Unityでも デモ動画

作った経緯・アイデア

  • インパクトのある題材にしようということでこのテーマを提案
  • 我慢要素を取り入れるためにトイレをすることをゴールとするように設計しました

アイデア出し(Figjam)

起承転結

image

  • 居酒屋に飲み会に行くとビールを思ったより飲んじゃう
  • 早く帰りたくて飲み会を終わらせるバッド飲みニケーションを目指す
  • トイレ専用の口コミサイトで調べると近場にトイレを発見!
  • 我慢しているうちにトイレへGO!!

技術的な話

Unity

Unityのリポジトリ

「起」を担当します。 ビールを飲む量をタイミングゲームで決めます。 ビールを飲みすぎると漏らしてしまいます。 残り容量ピッタリに飲む量を決められると次の「承」に繋がります。 image

使用技術

-Unity

 Unityを今回ほぼ初めて使用しました。タイミングゲームの作り方を調べ、それをもとにして仕様を変更し、ゲームを制作しました。
今回使用したゲーム素材をすべて自作しました。

詰まりポイント

UnityをWebGLでうまくビルドできない

 UnityをReactと統合させるためにWebGLでビルドしたが、ゲーム内の画像が映らないエラーが起きました。 この問題を解決するために、メンターに質問をしてCanvasに原因があることが分かりました。しかし、Canvasの内容を変えるとプログラムにエラーが起こり、プログラムを書き換えなければならず時間が足りませんでした。

以下の画像のようにゲームない画像が表示されないエラーが起きました。
image

Line

Lineのリポジトリ image https://line.me/R/ti/p/%40974zguze

FirebaseFunctionsにデプロイしてLineAPIのwebhookに設定しています

MessageingAPIでプレイヤーからの応答でゲームをします

FirebaseFunctionsにデプロイするために、以下のようなコードの形にしてます

linebotの関数として定義

export const lineBot = onRequest( { region: "asia-northeast1", secrets: [LINE_CHANNEL_SECRET, LINE_CHANNEL_ACCESS_TOKEN], }, async (req, res) => { // シークレット取得 if (process.env.FUNCTIONS_EMULATOR) { require('dotenv').config(); // Emulators 起動時だけ .env を読む } const channelSecret = process.env.LINE_CHANNEL_SECRET; const channelAccessToken = process.env.LINE_CHANNEL_ACCESS_TOKEN; // LINE SDK Client を生成 const lineClient = new Client({ channelSecret, channelAccessToken: channelAccessToken ?? "" }); // Express アプリを都度組み立て const app = express(); app.post( "/webhook", express.raw({ type: "application/json" }), (req, res, next) => { next(); }, lineMiddleware({ channelSecret: channelSecret ?? "" }), async (req, res) => { try { const events = (req.body as any).events || []; await Promise.all( events.map((event: any) => handleEvent(event, lineClient)) ); res.status(200).send("OK"); } catch (err) { logger.error("Webhook 処理中にエラー:", err); res.status(500).send("Internal Server Error"); } } ); // Express 実行 app(req, res); } );

handleEventとして実際のゲーム処理を書く

async function handleEvent(event: WebhookEvent, lineClient: Client): Promise<any> { const userId = event.source.userId; if (!userId) return; const profile = await lineClient.getProfile(userId as string); // 新規フォロー時はゲーム開始案内だけ if (event.type === "follow") { // Firestore に登録 await db.collection('users').doc(profile.userId).set({ hogehoge: xxxxxxxxx, // DBに登録する項目 }, { merge: true }); return lineClient.replyMessage((event as FollowEvent).replyToken, { type: "text", text: `登録完了` }); } // 反応+次フェーズのボタン return lineClient.replyMessage(messageEvent.replyToken, [ { type: "text", text: `hogehoge` }, makeButtonsTemplate(nextScenario) ]); }

ゲーム処理

シナリオ処理

  • シナリオを定義しています [フェーズ名、応答メッセージ、選択肢[主人公のメッセージ、それぞれのポイント、応答されるメッセージ]]
  • 毎回Functionとして新たに叩かれるのでユーザーのフェーズをfirestoreで管理
  • 選択肢をLineAPIのテンプレートメッセージで表示し、回答しやすくする
const SCENARIO: ScenarioType = { "phase1": { msg: "店長:「みなさん、乾杯!」\nあなた:(飲みすぎて少し落ち着かない…)\n選択肢:\n1. すみません、少し席を外す必要がありまして…\n2. 乾杯ー!(笑顔で軽くグラスだけ掲げる)\n3. あと一杯だけ…おかわりお願いします", choices: [ { text: "すみません、少し席を外す必要がありまして…", point: 3, react: "同僚A:「あれ?大丈夫?」と心配そうに小声で聞き返す。" }, { text: "乾杯ー!(笑顔で軽くグラスだけ掲げる)", point: 1, react: "同僚B:「お、元気だね!」と照れ笑いしつつ拍手。" }, { text: "あと一杯だけ…おかわりお願いします", point: 2, react: "店員:「かしこまりました」と手早くジョッキを用意しに行く。" }, ], next: "phase2-1" }, "phase2-1": { msg: "同僚C:「この週末、何か予定ある?」\nあなた:(そろそろ切り上げたい…)\n選択肢:\n1. 実はちょっと急ぎの用事を片付けたくて…そろそろ失礼してもいいですか?\n2. まだ未定だけど…みんなは?\n3. 週末は家でゆっくりしようかな…", choices: [ { text: "実はちょっと急ぎの用事を片付けたくて…そろそろ失礼してもいいですか?", point: 3, react: "同僚C:「あ、そうなんだ。じゃあ今日はここまでで!」と快く了承。" }, { text: "まだ未定だけど…みんなは?", point: 1, react: "同僚D:「そうなんだ!続きは週末に話そう!」と楽しげに話題継続。" }, { text: "週末は家でゆっくりしようかな…", point: 2, react: "同僚E:「それもいいね」と軽く同意。" }, ], next: "phase2-2" }, "phase2-2": { msg: "同僚F:「最近、休日は何してるの?」\nあなた:(早くお暇を…)\n選択肢:\n1. 趣味を楽しむ時間が取れなくて…今日はこれで失礼します\n2. 新しいゲームを始めてみたよ!\n3. 家で家族と過ごす予定だから…ラストオーダーで締めない?", choices: [ { text: "趣味を楽しむ時間が取れなくて…今日はこれで失礼します", point: 3, react: "同僚F:「あら、そうなの?じゃあまた今度ね」と気遣いの言葉。" }, { text: "新しいゲームを始めてみたよ!", point: 1, react: "同僚G:「へえ、面白そう!」と話を広げようとする。" }, { text: "家で家族と過ごす予定だから…ラストオーダーで締めない?", point: 2, react: "店長:「いい提案だね」と盛り上げる。" }, ], next: "phase2-3" }, "phase2-3": { msg: "同僚H:「あの案件、もうすぐ終わりそう?」\nあなた:(これ以上は耐えられない…)\n選択肢:\n1. 実は昨日徹夜で対応してて…今日は失礼します\n2. はい、もう少しで…詳細はまた後日!\n3. まだ山場だけど、どうにかなるよ!", choices: [ { text: "実は昨日徹夜で対応してて…今日は失礼します", point: 3, react: "同僚H:「マジか!お疲れさま…また報告聞かせて」と同情。" }, { text: "はい、もう少しで…詳細はまた後日!", point: 2, react: "同僚I:「了解!」と簡潔に切り上げムード。" }, { text: "まだ山場だけど、どうにかなるよ!", point: 1, react: "同僚J:「頼もしいね!」と笑顔で話継続。" }, ], next: "phase3" }, "phase3": { msg: "ホスト:「二次会、どこか行く人~?」\nあなた:(これ以上は控えたい…)\n選択肢:\n1. ごめんなさい、今日はちょっと控えます…お先に失礼します\n2. ちょっと予定を確認してから決めてもいいですか?\n3. ぜひ参加したいです!", choices: [ { text: "ごめんなさい、今日はちょっと控えます…お先に失礼します", point: 3, react: "ホスト:「あ、そうか…また今度ね」と少し残念そうに手を振る。" }, { text: "ちょっと予定を確認してから決めてもいいですか?", point: 2, react: "同僚K:「うん、急いでないから」と配慮してくれる。" }, { text: "ぜひ参加したいです!", point: 1, react: "ホスト:「おお、心強い!」と二次会の店探しに意欲を見せる。" }, ], next: "phase4" }, "phase4": { msg: "店員:「ラストオーダーです」\nあなた:(ここで最後の一手を…)\n選択肢:\n1. お会計をお願いします!\n2. 最後にもう一杯だけ…\n3. そろそろ失礼しますね…", choices: [ { text: "お会計をお願いします!", point: 3, react: "店員:「かしこまりました」とレジへ案内してくれる。" }, { text: "最後にもう一杯だけ…", point: 1, react: "同僚L:「お、もう一杯?」と期待の視線。" }, { text: "そろそろ失礼しますね…", point: 2, react: "店長:「おっと、そうか。じゃあ今日はここまでだね」と時計を見る。" }, ], next: "end" } };
return lineClient.replyMessage(messageEvent.replyToken, [ { type: "text", text: `${choice.react}\n${getPointComment(choice.point)}` }, makeButtonsTemplate(nextScenario) ]);
// ボタンテンプレート生成 function makeButtonsTemplate(scenario: ScenarioPhase) { return { type: "template" as const, altText: "選択肢を選んでください", template: { type: "buttons" as const, text: scenario.msg, actions: scenario.choices.map((choice, idx) => ({ type: "message" as const, label: `${idx+1}`, text: `${idx+1}` })) } } }

Flutter

Flutterのリポジトリ

近場のトイレを探して見つかった数が「結」のゲームに影響します

  • GoogleMapAPI
  • flutter-sound(音声収録・デシベル計測)
  • permission_handler(現在位置取得)

React

Reactリポジトリ

トイレを探して街を駆け回ります 我慢ポイントがあり、それが満タンになるとGameoverです 当初はVRペプシマンを作る予定だったのでコードなどに名残が残ってます

使用技術

  • Vite

  • ReactThreeFiber

  • ゴール数

  • アイテム処理

詰まりポイント

アイテムを取るたびにクソ重くなる

なぜか... → 全体を統合している部分でUIの処理とr3fのゲーム処理が同期していて以下のようになってしまう

  1. アイテムを取る
  2. ポイントが増加する
  3. UIが更新される
  4. ゲーム部分も一緒に更新されてモデル等がサイレンダリングされる

対策

  1. useMemoを使ってメモ化
  2. UIとゲームSceneを分離
const Game: React.FC = () => { return ( <Box w="100vw" h="100vh" position="relative" overflow="hidden"> {/* ゲームUI */} {/* ゲーム開始ボタン(中央オーバーレイ) */} {!gameStarted && ( <Center position="absolute" top={0} left={0} w="100%" h="100%" bg="rgba(0,0,0,0.75)" zIndex="overlay" > <Button size="lg" colorScheme="teal" onClick={() => setGameStarted(true)} px={8} py={6} fontSize="2xl" boxShadow="lg" > ゲームを始める </Button> </Center> )} {/* UI オーバーレイ */} {gameStarted && ( <Box position="absolute" top="10px" left="10px" p={4} bg="rgba(0,0,0,0.6)" borderRadius="md" zIndex={999} > <Text color="white" mb={2}>我慢ゲージ</Text> <Progress.Root max={100} value={patience} size="sm" colorPalette="cyan" mb={4}> <Progress.Track> <Progress.Range /> </Progress.Track> </Progress.Root> <Text color="white">ポイント: {currentPoint}</Text> </Box> )} {showGameOverModal && ( <GameOverModal onClose={() => setShowGameOverModal(false)} /> )} {/* r3fのゲームシーン */} <Scene gameStarted={gameStarted} goalCount={goalCount} initialGoalPos={initialGoalPos} onPointChange={handlePointChange} onGameOver={handleGameOver} onPatienceChange={handlePatienceChange} /> </Box> ); }; export default Game;

r3f内にUIを作成する

r3f内にUIを作成しようとすると座標が固定されて追従の処理が完成しませんでした。

対策

いったんVR化を諦めて画面外にUIを作成し、重ねて表示することで実質r3f上にUIが出ているようにした

全体

  • localStorageにユーザーID保存(よくなさそう)
  • firebaseでバックエンド(firestore・functions・hosting)
  • 統合・デプロイ
  • firestoreのデータ構造
  • Unity・flutterはiframeでマウントしています
  • LineはどうしようもないのでLine画面に誘導します
  • GitSubmoduleで4つあるリポジトリを統合
users-[userId]- userId | - gameState | - displayName | - taikState toielets- name (トイレの名前) | - location(座標) | - cleanliness( 綺麗さ ) | - comment

インフラ構成図

image

ウオミー

@KOU050223