推しアイデア
飲み会という普遍的なイベントから、「トイレを我慢する」という緊張感と面白味を持ったストーリーにしたこと。
飲み会という普遍的なイベントから、「トイレを我慢する」という緊張感と面白味を持ったストーリーにしたこと。
今回のテーマが「4コマ漫画の起承転結」だったので、インパクトのあるストーリーにしたかった。
4つの技術を使ったこと。
「起」を担当します。
ビールを飲む量をタイミングゲームで決めます。
ビールを飲みすぎると漏らしてしまいます。
残り容量ピッタリに飲む量を決められると次の「承」に繋がります。
使用技術
-Unity
Unityを今回ほぼ初めて使用しました。タイミングゲームの作り方を調べ、それをもとにして仕様を変更し、ゲームを制作しました。
今回使用したゲーム素材をすべて自作しました。
UnityをReactと統合させるためにWebGLでビルドしたが、ゲーム内の画像が映らないエラーが起きました。 この問題を解決するために、メンターに質問をしてCanvasに原因があることが分かりました。しかし、Canvasの内容を変えるとプログラムにエラーが起こり、プログラムを書き換えなければならず時間が足りませんでした。
以下の画像のようにゲームない画像が表示されないエラーが起きました。
Lineのリポジトリ
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) ]); }
シナリオ処理
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}` })) } } }
近場のトイレを探して見つかった数が「結」のゲームに影響します
トイレを探して街を駆け回ります 我慢ポイントがあり、それが満タンになるとGameoverです 当初はVRペプシマンを作る予定だったのでコードなどに名残が残ってます
使用技術
Vite
ReactThreeFiber
ゴール数
アイテム処理
なぜか... → 全体を統合している部分でUIの処理とr3fのゲーム処理が同期していて以下のようになってしまう
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を作成しようとすると座標が固定されて追従の処理が完成しませんでした。
いったんVR化を諦めて画面外にUIを作成し、重ねて表示することで実質r3f上にUIが出ているようにした
users-[userId]- userId | - gameState | - displayName | - taikState toielets- name (トイレの名前) | - location(座標) | - cleanliness( 綺麗さ ) | - comment