エムオーテックス株式会社が運営するテックブログです。

開発本部コンパで盛り上がった!以心伝心ゲームを作ってみた

開発本部コンパで盛り上がった!以心伝心ゲームを作ってみた

はじめに

こんにちは。アプリケーションチームの大市です。

開発本部のコンパで何か面白いことをやりたい!そんな思いから、チーム内のコミュニケーションを活性化させる「以心伝心ゲーム」を実施しました。

※社内懇親会をエムオーテックスではコンパと呼んでいます。

「以心伝心ゲーム」をするためのアプリケーションを生成 AI に協力してもらいながら実装したので、その体験をご紹介します。

ゲームの概要

「〇〇と言えば」というお題に対して、チーム内で同じ回答をした人の人数が得点となる協力型ゲームです。

ゲームをするにあたって、どのように参加者に回答してもらうのかが問題となりました。 一人一人紙に書くには、用意も書く方も大変です。 どうしようと悩んでいる時にふと、

  • 今の時代、大体の人はスマホを持っている。
  • 弊社の製品の性質上検証用のスマホもある。

というところから、スマホを使えばいいじゃないかと思い立ちました。 そこで、いいサービスが無いかを探そうと思いましたが、

「そうだ。生成 AI に作ってもらおう」

と思い立ち、実際に作ってもらいました。

ゲーム画面

最終的にこんな感じに仕上がりました。

ユーザー画面
管理者画面

ゲームの流れ

  1. お題を出題する
  2. それぞれ、スマホで回答を入力する
  3. 全員の回答を映す

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 伝わるわけではないので、人に説明するのと同じように伝えることを省略してはダメだなとも思いました。

今回作ったものはすごくシンプルな仕組みでしたが、それでも、十分に楽しめるアプリケーションになりました。 何より、開発したものが実際に使われて盛り上がってもらった体験は、エンジニアとして嬉しかったです!

この記事が、チーム内のコミュニケーション活性化の参考になれば幸いです!