
はじめに
こんにちは。アプリケーションチームの大市です。
開発本部のコンパで何か面白いことをやりたい!そんな思いから、チーム内のコミュニケーションを活性化させる「以心伝心ゲーム」を実施しました。
※社内懇親会をエムオーテックスではコンパと呼んでいます。
「以心伝心ゲーム」をするためのアプリケーションを生成 AI に協力してもらいながら実装したので、その体験をご紹介します。
ゲームの概要
「〇〇と言えば」というお題に対して、チーム内で同じ回答をした人の人数が得点となる協力型ゲームです。
ゲームをするにあたって、どのように参加者に回答してもらうのかが問題となりました。 一人一人紙に書くには、用意も書く方も大変です。 どうしようと悩んでいる時にふと、
- 今の時代、大体の人はスマホを持っている。
- 弊社の製品の性質上検証用のスマホもある。
というところから、スマホを使えばいいじゃないかと思い立ちました。 そこで、いいサービスが無いかを探そうと思いましたが、
「そうだ。生成 AI に作ってもらおう」
と思い立ち、実際に作ってもらいました。
ゲーム画面
最終的にこんな感じに仕上がりました。


ゲームの流れ
- お題を出題する
- それぞれ、スマホで回答を入力する
- 全員の回答を映す
1 は別途スライドを作っていたため、2 と 3 を実施するためのアプリケーションを作りました。
実装にあたって
実装にあたって、下記を想定しました。
- 開発はすべて Kiro-CLI にお任せ
- 簡単に使いたいので、ブラウザで動くものにしてもらう
- クラウド環境は、検証で使用している AWS 環境を借りて
- すぐに作って、すぐに消せるように CloudFormation のテンプレートにしてもらう
開発の流れ
まずはざっくりと作ってもらう
まずは、どんなものが作れるのかの実験も含めて、特に何も考えずに Kiro-CLI にざっくりと下記でお願いしてみました。
> 下記のゲーム実装して # 以心伝心ゲーム ## 概要 * ユーザー側と管理側の画面が別々にある * htmlとjsで実装 ## ゲームの流れ 1. [ユーザー側] 名前とチーム名を入力 2. [ユーザー側] 回答を入力。管理側に送信する 3. [管理側] ユーザーの回答を一覧表示
この段階でも、最低限のそれらしいものを作ってくれました。もう少しだけ見栄えを良くしてとお願いし、CSS を整えてもらいました。
ただし、この段階では、クラウドの話とかは特に入れられていなかったので、ユーザー側 の回答結果がローカルストレージに保存される仕組みで実装されていました。
実際のゲームでは、参加者全員が同じ PC で操作するわけにはいかないため、AWS 環境を使って API を作ることにしました。
クラウド上で動くように改修してもらう
次は、クラウド上で動くように
「AWS上で使えるようにして、ユーザーと管理者が別のデバイスで使えるようにして」
と丸投げをしてみました。丸投げしすぎました。 実装してくれてそうなのですが、私の中でこういう構成かなというのが頭の中にあり、それと噛み合わないためよく分からないことになってきてしまいました。
こういう時は、一回立ち止まって。
クラウド上で動くようにするところから始め直しました。 私の中に想定があるのなら、それを1つずつ実装してもらうように対話していきました。
細かいところを調整していく
私の中では、下記のような構成が頭の中にあり、作成と削除を簡単にしたいので、CloudFormationで作りたいと思っていました。

Kiro-CLI に順番に作っていくことを最初に伝えておき、下記のやり取りをしました。
[私] 順番に作っていこうと思って。最終的にCloudFormationで作れるようにしたいから、まずはAPIGWのテンプレートを作って欲しいな。 [K] なるほど!段階的に作っていくのね。まずはAPI Gatewayのテンプレートから作るよ。 [私] APIGWのエンドポイントは一旦なしで [K] エンドポイントなしのAPI Gatewayテンプレートに修正するね! [私] 次は、adminとuserのhtmlを返すエンドポイントをそれぞれ作って欲しいな。htmlはS3に置こうかな。 [K] S3からHTMLを返すエンドポイントを作るね! [私] あ、ごめん。1つのテンプレートにしたいや [K] デプロイメントとステージを追加するね!
これでおおよそ出来上がったので、ゲーム進行を想定しながら、便利そうな細かい機能をつけていきました。
- ユーザー側は、最初に1回だけ名前入力をしてもらうようにしました。
- チーム名をパスパラメータで指定するようにしました。
- 回答は一斉に見えるようにしたかったので、回答を更新ボタンを作りました。
- 回答結果を PDF で保存できるようにしました。
開発のポイント
- やりたいことが明確だったので、対話しながら順番にリクエストしていくと私自身も何をしているのか分かりやすかったです。
- 管理者側とユーザー側をそれぞれ作成したので、想定していたゲーム進行ができました。
- ユーザー側は 2次元コードの読み込みでチームが分けられるようにパスパラメータにチーム名が入れられるようにしました。
- 2時間くらいででき上がりました。
妥協した点
- 何が一緒なのかの判別(漢字、平仮名の違いなど)が難しいため、採点はアナログで行うことにしました。
できたもの
最終的に下記のものができました。
CloudFormation で作成された S3 バケットに 2つの HTML ファイルを置けば完成です。
CloudFormation テンプレート
AWSTemplateFormatVersion: '2010-09-09' Description: 'Game Application with API Gateway and HTML endpoints' Resources: # DynamoDB Table GameTable: Type: AWS::DynamoDB::Table Properties: TableName: GameAnswers BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: teamName AttributeType: S - AttributeName: timestamp AttributeType: S KeySchema: - AttributeName: teamName KeyType: HASH - AttributeName: timestamp KeyType: RANGE # Lambda Function GameFunction: Type: AWS::Lambda::Function Properties: FunctionName: GameFunction Runtime: nodejs18.x Handler: index.handler Code: ZipFile: | const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); const { DynamoDBDocumentClient, PutCommand, ScanCommand, DeleteCommand } = require('@aws-sdk/lib-dynamodb'); const client = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(client); const tableName = process.env.TABLE_NAME; exports.handler = async (event) => { const headers = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,X-Client-Type', 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS' }; if (event.httpMethod === 'OPTIONS') { return { statusCode: 200, headers }; } // クライアント判別 const clientType = event.headers['X-Client-Type'] || event.headers['x-client-type']; console.log('Client Type:', clientType); try { if (event.httpMethod === 'POST') { if (clientType !== 'user') { return { statusCode: 403, headers, body: JSON.stringify({ error: 'Unauthorized' }) }; } const body = JSON.parse(event.body); const { userName, teamName, answer } = body; await docClient.send(new PutCommand({ TableName: tableName, Item: { teamName, timestamp: new Date().toISOString(), userName, answer } })); return { statusCode: 200, headers, body: JSON.stringify({ message: 'Success' }) }; } if (event.httpMethod === 'GET') { if (clientType !== 'admin') { return { statusCode: 403, headers, body: JSON.stringify({ error: 'Unauthorized' }) }; } const result = await docClient.send(new ScanCommand({ TableName: tableName })); return { statusCode: 200, headers, body: JSON.stringify(result.Items || []) }; } if (event.httpMethod === 'DELETE') { if (clientType !== 'admin') { return { statusCode: 403, headers, body: JSON.stringify({ error: 'Unauthorized' }) }; } const result = await docClient.send(new ScanCommand({ TableName: tableName })); for (const item of result.Items || []) { await docClient.send(new DeleteCommand({ TableName: tableName, Key: { teamName: item.teamName, timestamp: item.timestamp } })); } return { statusCode: 200, headers, body: JSON.stringify({ message: 'All answers deleted' }) }; } } catch (error) { return { statusCode: 500, headers, body: JSON.stringify({ error: error.message }) }; } }; Environment: Variables: TABLE_NAME: !Ref GameTable Role: !GetAtt LambdaExecutionRole.Arn # Lambda Execution Role LambdaExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: DynamoDBAccess PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - dynamodb:PutItem - dynamodb:Scan - dynamodb:DeleteItem Resource: !GetAtt GameTable.Arn # S3 Bucket for HTML files HtmlBucket: Type: AWS::S3::Bucket Properties: BucketName: !Sub 'lspan-game-html-${AWS::AccountId}-${AWS::Region}' # REST API GameApi: Type: AWS::ApiGateway::RestApi Properties: Name: GameApi Description: API for Game Application EndpointConfiguration: Types: - REGIONAL # /admin リソース AdminResource: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref GameApi ParentId: !GetAtt GameApi.RootResourceId PathPart: admin # /user リソース UserResource: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref GameApi ParentId: !GetAtt GameApi.RootResourceId PathPart: user # /user/{teamName} リソース UserTeamResource: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref GameApi ParentId: !Ref UserResource PathPart: '{teamName}' # /answers リソース AnswersResource: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref GameApi ParentId: !GetAtt GameApi.RootResourceId PathPart: answers # Lambda Permission LambdaPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref GameFunction Action: lambda:InvokeFunction Principal: apigateway.amazonaws.com SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GameApi}/*/*' # Admin GET メソッド AdminGetMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref GameApi ResourceId: !Ref AdminResource HttpMethod: GET AuthorizationType: NONE Integration: Type: AWS IntegrationHttpMethod: GET Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:s3:path/${HtmlBucket}/admin.html' Credentials: !GetAtt ApiGatewayS3Role.Arn IntegrationResponses: - StatusCode: 200 ResponseParameters: method.response.header.Content-Type: "'text/html; charset=utf-8'" MethodResponses: - StatusCode: 200 ResponseParameters: method.response.header.Content-Type: false # User GET メソッド (チーム名パスパラメータ付き) UserTeamGetMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref GameApi ResourceId: !Ref UserTeamResource HttpMethod: GET AuthorizationType: NONE Integration: Type: AWS IntegrationHttpMethod: GET Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:s3:path/${HtmlBucket}/user.html' Credentials: !GetAtt ApiGatewayS3Role.Arn IntegrationResponses: - StatusCode: 200 ResponseParameters: method.response.header.Content-Type: "'text/html; charset=utf-8'" MethodResponses: - StatusCode: 200 ResponseParameters: method.response.header.Content-Type: false # User GET メソッド (元のまま) UserGetMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref GameApi ResourceId: !Ref UserResource HttpMethod: GET AuthorizationType: NONE Integration: Type: AWS IntegrationHttpMethod: GET Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:s3:path/${HtmlBucket}/user.html' Credentials: !GetAtt ApiGatewayS3Role.Arn IntegrationResponses: - StatusCode: 200 ResponseParameters: method.response.header.Content-Type: "'text/html; charset=utf-8'" MethodResponses: - StatusCode: 200 ResponseParameters: method.response.header.Content-Type: false # Answers POST メソッド AnswersPostMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref GameApi ResourceId: !Ref AnswersResource HttpMethod: POST AuthorizationType: NONE Integration: Type: AWS_PROXY IntegrationHttpMethod: POST Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GameFunction.Arn}/invocations' MethodResponses: - StatusCode: 200 # Answers GET メソッド AnswersGetMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref GameApi ResourceId: !Ref AnswersResource HttpMethod: GET AuthorizationType: NONE Integration: Type: AWS_PROXY IntegrationHttpMethod: POST Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GameFunction.Arn}/invocations' MethodResponses: - StatusCode: 200 # Answers DELETE メソッド AnswersDeleteMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref GameApi ResourceId: !Ref AnswersResource HttpMethod: DELETE AuthorizationType: NONE Integration: Type: AWS_PROXY IntegrationHttpMethod: POST Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GameFunction.Arn}/invocations' MethodResponses: - StatusCode: 200 # Answers OPTIONS メソッド (CORS) AnswersOptionsMethod: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref GameApi ResourceId: !Ref AnswersResource HttpMethod: OPTIONS AuthorizationType: NONE Integration: Type: AWS_PROXY IntegrationHttpMethod: POST Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GameFunction.Arn}/invocations' MethodResponses: - StatusCode: 200 # API Gateway用のS3アクセスロール ApiGatewayS3Role: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: apigateway.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: S3ReadPolicy PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - s3:GetObject Resource: !Sub 'arn:aws:s3:::${HtmlBucket}/*' - Effect: Allow Action: - s3:ListBucket Resource: !Sub 'arn:aws:s3:::${HtmlBucket}' # デプロイメント ApiDeployment: Type: AWS::ApiGateway::Deployment DependsOn: - AdminGetMethod - UserGetMethod - UserTeamGetMethod - AnswersPostMethod - AnswersGetMethod - AnswersDeleteMethod - AnswersOptionsMethod Properties: RestApiId: !Ref GameApi # ステージ ApiStage: Type: AWS::ApiGateway::Stage Properties: RestApiId: !Ref GameApi DeploymentId: !Ref ApiDeployment StageName: prod Outputs: RestApiId: Description: REST API ID Value: !Ref GameApi HtmlBucketName: Description: S3 Bucket for HTML files Value: !Ref HtmlBucket AdminUrl: Description: Admin page URL Value: !Sub 'https://${GameApi}.execute-api.${AWS::Region}.amazonaws.com/prod/admin' UserUrl: Description: User page URL Value: !Sub 'https://${GameApi}.execute-api.${AWS::Region}.amazonaws.com/prod/user' ApiEndpoint: Description: API endpoint for answers Value: !Sub 'https://${GameApi}.execute-api.${AWS::Region}.amazonaws.com/prod/answers'
ユーザー画面
<!DOCTYPE html> <html> <head> <title>以心伝心ゲーム - ユーザー</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: 'Arial', sans-serif; max-width: 500px; margin: 20px auto; padding: 20px; box-sizing: border-box; background: linear-gradient(135deg, #fff8dc, #f0f8ff); min-height: 100vh; } h1 { text-align: center; color: #8b4513; font-size: 1.8em; text-shadow: 2px 2px 4px rgba(139,69,19,0.3); margin-bottom: 30px; } h1::before { content: "🐱 "; } h1::after { content: " 🐾"; } input { width: 100%; padding: 18px; margin: 15px 0; border: 2px solid #deb887; border-radius: 25px; font-size: 18px; box-sizing: border-box; background: rgba(255,255,255,0.9); transition: all 0.3s ease; } input:focus { border-color: #cd853f; box-shadow: 0 0 10px rgba(205,133,63,0.3); outline: none; } button { width: 100%; padding: 18px; background: linear-gradient(45deg, #daa520, #b8860b); color: white; border: none; border-radius: 25px; cursor: pointer; font-size: 18px; margin: 20px 0; box-shadow: 0 4px 15px rgba(218,165,32,0.4); transition: all 0.3s ease; font-weight: bold; min-height: 60px; } button:hover { background: linear-gradient(45deg, #b8860b, #8b7355); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(218,165,32,0.6); } button::before { content: "🐾 "; } #playerInfo { font-weight: bold; color: #8b4513; font-size: 1.1em; text-align: center; background: rgba(255,255,255,0.8); padding: 15px; border-radius: 20px; border: 2px solid #deb887; margin: 20px 0; line-height: 1.6; } #playerName, #teamInfo { font-weight: bold; color: #8b4513; } #playerInfo::before { content: "😸 "; } #setupForm, #gameForm { background: rgba(255,255,255,0.7); padding: 30px; border-radius: 20px; box-shadow: 0 8px 32px rgba(139,69,19,0.2); border: 1px solid rgba(222,184,135,0.3); position: relative; } #setupForm::before, #gameForm::before { content: "🐱"; position: absolute; top: -15px; right: 20px; font-size: 2em; background: white; padding: 5px 10px; border-radius: 50%; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } p { margin: 15px 0; text-align: center; } @media (max-width: 480px) { body { margin: 10px; padding: 15px; } h1 { font-size: 1.5em; } input, button { padding: 18px; font-size: 18px; min-height: 60px; } #setupForm, #gameForm { padding: 20px; } } </style> </head> <body> <h1>以心伝心ゲーム</h1> <div id="setupForm"> <input type="text" id="userName" placeholder="名前を入力"> <button onclick="startGame()">ゲーム開始</button> </div> <div id="gameForm" style="display:none;"> <p>チーム: <span id="teamInfo"></span></p> <p>プレイヤー: <span id="playerName"></span></p> <input type="text" id="answer" placeholder="回答を入力"> <button onclick="sendAnswer()">送信</button> </div> <script> let playerData = null; // チーム名と絵文字のマッピング function getTeamEmoji(teamName) { const emojiMap = { 'pumpkins': '🎃', 'ghosts': '👻', 'witches': '🧙<200d>♀️', 'vampires': '🧛<200d>♂️', 'skeletons': '💀', 'zombies': '🧟<200d>♂️', 'spiders': '🕷️', 'blackcats': '🐈<200d>⬛', 'bats': '🦇' }; return emojiMap[teamName.toLowerCase()] || '👥'; } // URLからチーム名を取得 function getTeamNameFromUrl() { const pathParts = window.location.pathname.split('/'); const encodedTeamName = pathParts[pathParts.length - 1]; // 最後の部分がチーム名 return decodeURIComponent(encodedTeamName); // URLデコード } function startGame() { const userName = document.getElementById('userName').value; const teamName = getTeamNameFromUrl(); if (!userName) { alert('名前を入力してください'); return; } if (!teamName || teamName === 'user') { alert('チーム名が指定されていません。正しいURLでアクセスしてください。'); return; } playerData = { userName, teamName }; const emoji = getTeamEmoji(teamName); document.getElementById('playerName').textContent = userName; document.getElementById('teamInfo').textContent = `${emoji} ${teamName}`; document.getElementById('setupForm').style.display = 'none'; document.getElementById('gameForm').style.display = 'block'; } async function sendAnswer() { const answer = document.getElementById('answer').value; if (!answer) { alert('回答を入力してください'); return; } try { const response = await fetch('/prod/answers', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Client-Type': 'user' }, body: JSON.stringify({ ...playerData, answer }) }); if (response.ok) { alert('送信しました!'); document.getElementById('answer').value = ''; } else { alert('送信に失敗しました'); } } catch (error) { alert('エラーが発生しました'); } } </script> </body> </html>
管理者画面
<!DOCTYPE html> <html> <head> <title>以心伝心ゲーム - 管理</title> <meta charset="UTF-8"> <style> body { font-family: Arial, sans-serif; max-width: 800px; margin: 20px auto; padding: 20px; background: linear-gradient(135deg, #fff8dc, #f0f8ff); min-height: 100vh; } h1 { text-align: center; color: #8b4513; text-shadow: 2px 2px 4px rgba(139,69,19,0.3); } h1::before { content: "🐱 "; } h1::after { content: " 👑"; } h3 { background: linear-gradient(45deg, #deb887, #d2b48c); padding: 15px; border-left: 4px solid #8b4513; margin: 20px 0 10px 0; border-radius: 10px; color: #654321; box-shadow: 0 2px 8px rgba(139,69,19,0.2); } h3::before { content: "🐾 "; } button { padding: 12px 24px; margin: 8px; background: linear-gradient(45deg, #daa520, #b8860b); color: white; border: none; border-radius: 20px; cursor: pointer; font-weight: bold; box-shadow: 0 4px 12px rgba(218,165,32,0.3); transition: all 0.3s ease; } button:hover { background: linear-gradient(45deg, #b8860b, #8b7355); transform: translateY(-2px); box-shadow: 0 6px 16px rgba(218,165,32,0.5); } button:nth-of-type(1)::before { content: "🔄 "; } button:nth-of-type(2) { background: linear-gradient(45deg, #4169e1, #1e90ff); } button:nth-of-type(2):hover { background: linear-gradient(45deg, #1e90ff, #0066cc); } button:nth-of-type(2)::before { content: "📄 "; } button:nth-of-type(3) { background: linear-gradient(45deg, #cd853f, #a0522d); } button:nth-of-type(3):hover { background: linear-gradient(45deg, #a0522d, #8b4513); } button:nth-of-type(3)::before { content: "🗑️ "; } table { border-collapse: collapse; width: 100%; margin-bottom: 30px; box-shadow: 0 4px 16px rgba(139,69,19,0.2); border-radius: 10px; overflow: hidden; background: white; } th, td { border: 1px solid #deb887; padding: 12px; text-align: left; } th { background: linear-gradient(45deg, #8b4513, #a0522d); color: white; font-weight: bold; } tr:nth-child(even) { background-color: #faf0e6; } tr:hover { background-color: #f5deb3; transition: background-color 0.3s ease; } #teamsContainer { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } #teamsContainer > div { background: rgba(255,255,255,0.8); margin: 20px 0; padding: 20px; border-radius: 15px; border: 2px solid #deb887; box-shadow: 0 4px 16px rgba(139,69,19,0.1); } @media (max-width: 768px) { #teamsContainer { grid-template-columns: 1fr; } } p { text-align: center; color: #8b4513; font-size: 1.2em; font-weight: bold; } p::before { content: "😿 "; } </style> </head> <body> <h1>以心伝心ゲーム - 管理画面</h1> <button onclick="loadAnswers()">回答を更新</button> <button onclick="downloadPDF()">PDFダウンロード</button> <button onclick="deleteAnswers()">回答を削除</button> <div id="teamsContainer"></div> <script> // チーム名と絵文字のマッピング function getTeamEmoji(teamName) { const emojiMap = { 'pumpkins': '🎃', 'ghosts': '👻', 'witches': '🧙<200d>♀️', 'vampires': '🧛<200d>♂️', 'skeletons': '💀', 'zombies': '🧟<200d>♂️', 'spiders': '🕷️', 'blackcats': '🐈<200d>⬛', 'bats': '🦇' }; return emojiMap[teamName.toLowerCase()] || '👥'; } // emojiMapの順番でチーム名をソート function sortTeamsByEmojiOrder(teamNames) { const emojiOrder = ['pumpkins', 'ghosts', 'witches', 'vampires', 'skeletons', 'zombies', 'spiders', 'blackcats', 'bats']; return teamNames.sort((a, b) => { const indexA = emojiOrder.indexOf(a.toLowerCase()); const indexB = emojiOrder.indexOf(b.toLowerCase()); return indexA - indexB; }); } async function loadAnswers() { try { const response = await fetch('/prod/answers', { headers: { 'X-Client-Type': 'admin' } }); const answers = await response.json(); const container = document.getElementById('teamsContainer'); container.innerHTML = ''; if (answers.length === 0) { container.innerHTML = '<p>回答がありません</p>'; return; } // チーム別にグループ化 const teams = {}; answers.forEach(answer => { if (!teams[answer.teamName]) { teams[answer.teamName] = []; } teams[answer.teamName].push(answer); }); // チーム別に表示(emojiMapの順番でソート) sortTeamsByEmojiOrder(Object.keys(teams)).forEach(teamName => { const teamDiv = document.createElement('div'); const emoji = getTeamEmoji(teamName); teamDiv.innerHTML = ` <h3>${emoji} ${teamName}</h3> <table> <thead> <tr><th>名前</th><th>回答</th></tr> </thead> <tbody> ${teams[teamName].map(answer => `<tr><td>${answer.userName}</td><td>${answer.answer}</td></tr>` ).join('')} </tbody> </table> `; container.appendChild(teamDiv); }); } catch (error) { alert('データ取得エラー: ' + error.message); } } async function downloadPDF() { try { const response = await fetch('/prod/answers', { headers: { 'X-Client-Type': 'admin' } }); const answers = await response.json(); if (answers.length === 0) { alert('ダウンロードする回答がありません'); return; } // チーム別にグループ化 const teams = {}; answers.forEach(answer => { if (!teams[answer.teamName]) { teams[answer.teamName] = []; } teams[answer.teamName].push(answer); }); // PDF用のHTMLを生成 let pdfContent = ` <html> <head> <meta charset="UTF-8"> <style> body { font-family: Arial, sans-serif; margin: 20px; } h1 { text-align: center; color: #8b4513; } h2 { color: #8b4513; border-bottom: 2px solid #deb887; padding-bottom: 5px; } table { border-collapse: collapse; width: 100%; margin-bottom: 30px; } th, td { border: 1px solid #deb887; padding: 8px; text-align: left; } th { background-color: #f5deb3; } .timestamp { font-size: 0.9em; color: #666; } </style> </head> <body> <h1>🐱 以心伝心ゲーム結果 🐾</h1> <p class="timestamp">出力日時: ${new Date().toLocaleString('ja-JP')}</p> `; sortTeamsByEmojiOrder(Object.keys(teams)).forEach(teamName => { const emoji = getTeamEmoji(teamName); pdfContent += ` <h2>${emoji} ${teamName}</h2> <table> <thead> <tr><th>名前</th><th>回答</th></tr> </thead> <tbody> ${teams[teamName].map(answer => `<tr><td>${answer.userName}</td><td>${answer.answer}</td></tr>` ).join('')} </tbody> </table> `; }); pdfContent += '</body></html>'; // 新しいウィンドウで開いて印刷ダイアログを表示 const printWindow = window.open('', '_blank'); printWindow.document.write(pdfContent); printWindow.document.close(); printWindow.focus(); setTimeout(() => { printWindow.print(); }, 500); } catch (error) { alert('PDFダウンロードエラー: ' + error.message); } } async function deleteAnswers() { if (!confirm('全ての回答を削除しますか?')) { return; } try { const response = await fetch('/prod/answers', { method: 'DELETE', headers: { 'X-Client-Type': 'admin' } }); if (response.ok) { alert('全ての回答を削除しました'); document.getElementById('teamsContainer').innerHTML = ''; } else { alert('削除に失敗しました'); } } catch (error) { alert('削除エラー: ' + error.message); } } </script> </body> </html>
さいごに
開発本部コンパ用の以心伝心ゲーム制作。たった 2 時間で完成した手軽さに、生成 AI の便利さを改めて実感しました。
実際のコンパでは、参加者全員がスマホでサクサク回答を入力し、管理画面で一斉に結果が表示される瞬間に笑い声や歓声が上がり、大いに盛り上がることができました。特に予想外の回答が出た時や、みんなの回答が見事に一致した時の反応は印象的でした。後日、「楽しかった!」との感想をいただくこともでき、作って良かったと実感しました。
また、自分の頭の中が直接生成 AI 伝わるわけではないので、人に説明するのと同じように伝えることを省略してはダメだなとも思いました。
今回作ったものはすごくシンプルな仕組みでしたが、それでも、十分に楽しめるアプリケーションになりました。 何より、開発したものが実際に使われて盛り上がってもらった体験は、エンジニアとして嬉しかったです!
この記事が、チーム内のコミュニケーション活性化の参考になれば幸いです!