🎮

【後半】GitHub と 生成 AI を活用して ミニゲームが遊べる LINE ミニアプリを開発するハンズオン

2024/12/10に公開
1

「GitHub と 生成 AI を活用して ミニゲームが遊べる LINE ミニアプリを開発するハンズオン」の後半記事です。

★ 前半はこちら:

https://zenn.dev/himanago/articles/github-line-miniapp-with-genai1

5. 生成 AI でミニゲームを開発

無事にミニアプリとしてデプロイできることが確認できたので、いよいよ中身を作っていきます。

まずは開発の準備のため、一度 Codespace のタブに戻ります。停止してしまっている場合は Restart してください。

pull しておく

Ctrl + Shift + p でコマンドパレットを開き、git pull と入力して Git: Pull (Rebase) を選択します。

生成 AI でミニゲームを開発する

ChatGPT や Claude などのチャット型の生成 AI サービスに、ミニゲームのコードを作ってもらいます。

使う生成 AI は好みのものでよいですが、無課金の方へのおすすめは Claude で初期生成し、ChatGPT で調整するという方法です。

ここからはおすすめの方法(Claude で初期生成し、ChatGPT で調整)で進めていきますが、ハンズオンではお好きなサービスを使っていただいて構いません。

まず Claude を新しいタブで開き、サインインします。

Claude の Artifact 機能がオンになっている必要があるので、確認します。

左側にカーソルを持っていくと、下のほうにアカウント情報が表示されるのでクリックします。

settings をクリックします

「Enable artifacts」 を確認し、オフの場合はオンにします。

設定を確認したら、左上にカーソルをもっていき、「start new chat」をクリックします。

チャットが開始されるので、以下のいずれかのプロンプトを入力します。どnゲームのジャンルを指定しているかの違いですが、別のジャンルを指定したり、あえて指定しないで作らせたりしても OK です。

細かいプロンプトエンジニアリングのようなものはひとまず気にせずやっていきます。

React で動く、canvas を使った 2D のシューティングゲームを作ってください。
クリック操作のみで遊べるものにしてください。
React で動く、canvas を使った 2D の横スクロールアクションゲームを作ってください。
クリック操作のみで遊べるものにしてください。
React で動く、canvas を使った 2D のフルーツキャッチゲームを作ってください。
クリック操作のみで遊べるものにしてください。

なお canvas を指定しているのは、React の DOM 操作でゲームを作ってしまいパフォーマンスが落ちるのを防ぐためです。

Artifact 機能をオンにしていると、画面右側で成果物の動作を直接確認できます。

動作確認をして改善してほしいことがあれば、不具合の内容や改善してほしい点を なるべく具体的に チャットで伝えます。

修正プロンプト例:

動作していません。画面が真っ白です。
スコアが増えません。
障害物のスピードが速すぎます。
プレイヤーの移動がうまくいきません。
ゲームオーバーになりません。

複数の修正点を箇条書きで送っても大丈夫です。

もし、Claude の使用制限が来てしまったら、ChatGPT に切り替えていきます。

Claude で作らせると、ゲームをコンポーネントとして作ってくれるはずなので、成果物と同じファイル名で新規ファイルで作成し、それを App.jsx で呼び出す形で作ります。

Claude の成果物の場合、たとえば以下のようにします。

ShootingGame.jsx(新規ファイル)

src 内に新しいファイルとして作成します。

import React, { useState, useRef, useEffect } from 'react';
import { Card } from '@/components/ui/card';

const ShootingGame = () => {
  const canvasRef = useRef(null);
  const [score, setScore] = useState(0);
  const [gameOver, setGameOver] = useState(false);
  
  // ゲーム状態を保持するRef
  const gameStateRef = useRef({
    player: null,
    enemies: [],
    bullets: [],
    config: null,
    ctx: null,
    isDragging: false,
    animationFrameId: null
  });

  // ゲームの初期化
  const initGame = () => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    
    // ゲーム設定
    const config = {
      width: 400,
      height: 600,
      playerSize: 40,
      enemySize: 30,
      bulletSize: 10,
      playerColor: '#4CAF50',
      enemyColor: '#F44336',
      bulletColor: '#2196F3'
    };

    // ゲーム状態の更新
    gameStateRef.current = {
      player: { x: config.width / 2, y: config.height - 100 },
      enemies: [],
      bullets: [],
      config: config,
      ctx: ctx,
      isDragging: false,
      animationFrameId: null
    };

    // 敵の生成
    const createEnemy = () => {
      const { enemies, config } = gameStateRef.current;
      const x = Math.random() * (config.width - config.enemySize);
      enemies.push({ x, y: 0 });
    };

    // ゲームループ
    const gameLoop = () => {
      const { player, enemies, bullets, config, ctx } = gameStateRef.current;

      ctx.clearRect(0, 0, config.width, config.height);

      // 敵の生成と移動
      if (Math.random() < 0.03) createEnemy();
      
      // 弾丸と敵の当たり判定をここに移動
      for (let i = bullets.length - 1; i >= 0; i--) {
        for (let j = enemies.length - 1; j >= 0; j--) {
          const bullet = bullets[i];
          const enemy = enemies[j];
          
          if (
            bullet &&
            enemy &&
            bullet.x < enemy.x + config.enemySize &&
            bullet.x + config.bulletSize > enemy.x &&
            bullet.y < enemy.y + config.enemySize &&
            bullet.y + config.bulletSize > enemy.y
          ) {
            // 敵と弾丸を削除
            enemies.splice(j, 1);
            bullets.splice(i, 1);
            
            // スコア更新
            setScore(prevScore => prevScore + 1);
            
            // ループを抜ける(1つの弾丸は1体の敵にのみ当たる)
            break;
          }
        }
      }

      // 敵の移動と画面外チェック
      for (let i = enemies.length - 1; i >= 0; i--) {
        const enemy = enemies[i];
        enemy.y += 3;
        
        ctx.fillStyle = config.enemyColor;
        ctx.fillRect(enemy.x, enemy.y, config.enemySize, config.enemySize);

        // 敵の画面外チェック
        if (enemy.y > config.height) {
          enemies.splice(i, 1);
        }
      }

      // 弾丸の移動と画面外チェック
      for (let i = bullets.length - 1; i >= 0; i--) {
        const bullet = bullets[i];
        bullet.y -= 5;
        
        ctx.fillStyle = config.bulletColor;
        ctx.fillRect(bullet.x, bullet.y, config.bulletSize, config.bulletSize);

        // 弾丸の画面外チェック
        if (bullet.y < 0) {
          bullets.splice(i, 1);
        }
      }

      // プレイヤーの描画
      ctx.fillStyle = config.playerColor;
      ctx.fillRect(
        player.x, 
        player.y, 
        config.playerSize, 
        config.playerSize
      );

      // ゲームオーバー判定
      const gameOverCheck = enemies.some(enemy => 
        enemy.y + config.enemySize > config.height - 100
      );

      if (gameOverCheck) {
        setGameOver(true);
      } else {
        // アニメーションフレームIDを保存
        gameStateRef.current.animationFrameId = requestAnimationFrame(gameLoop);
      }
    };

    // キャンバスサイズ設定
    canvas.width = config.width;
    canvas.height = config.height;

    // ゲームループ開始
    gameLoop();
  };

  // 弾丸の生成
  const createBullet = (event) => {
    if (gameOver) {
      initGame();
      setGameOver(false);
      setScore(0);
      return;
    }

    const { player, bullets, config } = gameStateRef.current;
    const bulletX = player.x + config.playerSize / 2 - config.bulletSize / 2;
    const bulletY = player.y;
    bullets.push({ x: bulletX, y: bulletY });
  };

  // プレイヤーの移動ハンドラ
  const handlePlayerMove = (event) => {
    event.preventDefault();
    const canvas = canvasRef.current;
    const { player, config, isDragging } = gameStateRef.current;

    // タッチイベントとマウスイベントの座標を取得
    const clientX = event.touches 
      ? event.touches[0].clientX 
      : event.clientX;

    // キャンバスの位置を取得
    const rect = canvas.getBoundingClientRect();
    
    // 新しいプレイヤー位置を計算
    const newX = clientX - rect.left - config.playerSize / 2;
    
    // プレイヤーの移動範囲を制限
    const limitedX = Math.max(
      0, 
      Math.min(newX, config.width - config.playerSize)
    );

    // ゲーム状態を更新
    if (player) {
      player.x = limitedX;
    }
  };

  // ドラッグ開始
  const handleDragStart = (event) => {
    event.preventDefault();
    gameStateRef.current.isDragging = true;
    handlePlayerMove(event);
  };

  // ドラッグ中
  const handleDragMove = (event) => {
    event.preventDefault();
    const { isDragging } = gameStateRef.current;
    if (isDragging) {
      handlePlayerMove(event);
    }
  };

  // ドラッグ終了
  const handleDragEnd = (event) => {
    event.preventDefault();
    gameStateRef.current.isDragging = false;
  };

  // コンポーネントマウント時にゲーム初期化とイベントリスナー追加
  useEffect(() => {
    initGame();
    const canvas = canvasRef.current;

    // マウスイベントリスナー
    canvas.addEventListener('mousedown', handleDragStart);
    canvas.addEventListener('mousemove', handleDragMove);
    canvas.addEventListener('mouseup', handleDragEnd);
    canvas.addEventListener('mouseleave', handleDragEnd);

    // タッチイベントリスナー
    canvas.addEventListener('touchstart', handleDragStart);
    canvas.addEventListener('touchmove', handleDragMove);
    canvas.addEventListener('touchend', handleDragEnd);

    // クリーンアップ関数
    return () => {
      // アニメーションフレームをキャンセル
      const { animationFrameId } = gameStateRef.current;
      if (animationFrameId) {
        cancelAnimationFrame(animationFrameId);
      }

      canvas.removeEventListener('mousedown', handleDragStart);
      canvas.removeEventListener('mousemove', handleDragMove);
      canvas.removeEventListener('mouseup', handleDragEnd);
      canvas.removeEventListener('mouseleave', handleDragEnd);
      canvas.removeEventListener('touchstart', handleDragStart);
      canvas.removeEventListener('touchmove', handleDragMove);
      canvas.removeEventListener('touchend', handleDragEnd);
    };
  }, []);

  return (
    <Card className="flex flex-col items-center p-4">
      <div className="mb-4">
        <h2 className="text-xl font-bold">クリックシューティングゲーム</h2>
        <p>スコア: {score}</p>
        {gameOver && <p className="text-red-500">ゲームオーバー!クリックでリスタート</p>}
      </div>
      <canvas 
        ref={canvasRef} 
        onClick={createBullet}
        className="border-2 border-gray-300 touch-none"
      />
      <p className="mt-2 text-sm text-gray-600">
        プレイヤーをドラッグまたはタッチして移動、クリックで弾を撃つ
      </p>
    </Card>
  );
};

export default ShootingGame;

App.jsx(既存ファイル)

次に既存の App.jsx でそれを呼び出します。

import { useEffect, useState } from "react";
import liff from "@line/liff";
import "./App.css";
import ShootingGame from "./ShootingGame";  // ←追記

function App() {
  const [name, setName] = useState("");

  useEffect(() => {
    liff
      .init({
        liffId: import.meta.env.VITE_LIFF_ID
      })
      .then(() => {
        liff.getProfile()
          .then((profile) => {
            setName(profile.displayName);
          })
      })
  }, []);
  
  return (
    <div className="App">
      {name && <p>こんにちは、{name}さん</p>}
      <ShootingGame />  <!-- 追記 -->
    </div>
  );
}

export default App;

必要なライブラリのインストール

ShootingGame で使っている @/components/ui/card は、追加でインストールが必要です。

どのようにインストールすればよいかはそのまま Claude や ChatGPT に聞いてみてください。

しかし、うまくいかないこともあるので、余計なライブラリを使わない形に修正してもらうことも有用です。

今回は、Claude から以下のような回答があったので、単純に div タグに置き換えることにしました。


shadcn/ui のコンポーネントを使用するには、以下の手順を実行する必要があります:

  1. まず、shadcn/ui のCLIをインストールします:
npm install -D @shadcn/ui
  1. 次に、Cardコンポーネントをインストールします:
npx shadcn-ui@latest add card

このコマンドは以下のことを行います:

  • 必要なコンポーネントファイルを components/ui/ ディレクトリに追加
  • 必要な依存関係(tailwind-merge、class-variance-authorityなど)をインストール

注意点:

  • このコマンドには、プロジェクトにTailwind CSSがすでにセットアップされている必要があります
  • React プロジェクト(Next.jsなど)で実行する必要があります

もし上記のコマンドでエラーが発生する場合は、以下の追加手順を試してください:

npm install tailwindcss-animate class-variance-authority clsx tailwind-merge
npx tailwindcss init

コンポーネントを追加できない、または使用できない場合は、単純に<div>タグなどの標準的なHTML要素に置き換えることもできます。

追加の詳細や特定の環境での設定について、さらに具体的な情報が必要であれば教えてください。


修正後の ShootingGame.jsx

import React, { useState, useRef, useEffect } from 'react';

const ShootingGame = () => {
  const canvasRef = useRef(null);
  const [score, setScore] = useState(0);
  const [gameOver, setGameOver] = useState(false);
  
  // ゲーム状態を保持するRef
  const gameStateRef = useRef({
    player: null,
    enemies: [],
    bullets: [],
    config: null,
    ctx: null,
    isDragging: false,
    animationFrameId: null
  });

  // ゲームの初期化
  const initGame = () => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    
    // ゲーム設定
    const config = {
      width: 400,
      height: 600,
      playerSize: 40,
      enemySize: 30,
      bulletSize: 10,
      playerColor: '#4CAF50',
      enemyColor: '#F44336',
      bulletColor: '#2196F3'
    };

    // ゲーム状態の更新
    gameStateRef.current = {
      player: { x: config.width / 2, y: config.height - 100 },
      enemies: [],
      bullets: [],
      config: config,
      ctx: ctx,
      isDragging: false,
      animationFrameId: null
    };

    // 敵の生成
    const createEnemy = () => {
      const { enemies, config } = gameStateRef.current;
      const x = Math.random() * (config.width - config.enemySize);
      enemies.push({ x, y: 0 });
    };

    // ゲームループ
    const gameLoop = () => {
      const { player, enemies, bullets, config, ctx } = gameStateRef.current;

      ctx.clearRect(0, 0, config.width, config.height);

      // 敵の生成と移動
      if (Math.random() < 0.03) createEnemy();
      
      // 弾丸と敵の当たり判定をここに移動
      for (let i = bullets.length - 1; i >= 0; i--) {
        for (let j = enemies.length - 1; j >= 0; j--) {
          const bullet = bullets[i];
          const enemy = enemies[j];
          
          if (
            bullet &&
            enemy &&
            bullet.x < enemy.x + config.enemySize &&
            bullet.x + config.bulletSize > enemy.x &&
            bullet.y < enemy.y + config.enemySize &&
            bullet.y + config.bulletSize > enemy.y
          ) {
            // 敵と弾丸を削除
            enemies.splice(j, 1);
            bullets.splice(i, 1);
            
            // スコア更新
            setScore(prevScore => prevScore + 1);
            
            // ループを抜ける(1つの弾丸は1体の敵にのみ当たる)
            break;
          }
        }
      }

      // 敵の移動と画面外チェック
      for (let i = enemies.length - 1; i >= 0; i--) {
        const enemy = enemies[i];
        enemy.y += 3;
        
        ctx.fillStyle = config.enemyColor;
        ctx.fillRect(enemy.x, enemy.y, config.enemySize, config.enemySize);

        // 敵の画面外チェック
        if (enemy.y > config.height) {
          enemies.splice(i, 1);
        }
      }

      // 弾丸の移動と画面外チェック
      for (let i = bullets.length - 1; i >= 0; i--) {
        const bullet = bullets[i];
        bullet.y -= 5;
        
        ctx.fillStyle = config.bulletColor;
        ctx.fillRect(bullet.x, bullet.y, config.bulletSize, config.bulletSize);

        // 弾丸の画面外チェック
        if (bullet.y < 0) {
          bullets.splice(i, 1);
        }
      }

      // プレイヤーの描画
      ctx.fillStyle = config.playerColor;
      ctx.fillRect(
        player.x, 
        player.y, 
        config.playerSize, 
        config.playerSize
      );

      // ゲームオーバー判定
      const gameOverCheck = enemies.some(enemy => 
        enemy.y + config.enemySize > config.height - 100
      );

      if (gameOverCheck) {
        setGameOver(true);
      } else {
        // アニメーションフレームIDを保存
        gameStateRef.current.animationFrameId = requestAnimationFrame(gameLoop);
      }
    };

    // キャンバスサイズ設定
    canvas.width = config.width;
    canvas.height = config.height;

    // ゲームループ開始
    gameLoop();
  };

  // 弾丸の生成
  const createBullet = (event) => {
    if (gameOver) {
      initGame();
      setGameOver(false);
      setScore(0);
      return;
    }

    const { player, bullets, config } = gameStateRef.current;
    const bulletX = player.x + config.playerSize / 2 - config.bulletSize / 2;
    const bulletY = player.y;
    bullets.push({ x: bulletX, y: bulletY });
  };

  // プレイヤーの移動ハンドラ
  const handlePlayerMove = (event) => {
    event.preventDefault();
    const canvas = canvasRef.current;
    const { player, config, isDragging } = gameStateRef.current;

    // タッチイベントとマウスイベントの座標を取得
    const clientX = event.touches 
      ? event.touches[0].clientX 
      : event.clientX;

    // キャンバスの位置を取得
    const rect = canvas.getBoundingClientRect();
    
    // 新しいプレイヤー位置を計算
    const newX = clientX - rect.left - config.playerSize / 2;
    
    // プレイヤーの移動範囲を制限
    const limitedX = Math.max(
      0, 
      Math.min(newX, config.width - config.playerSize)
    );

    // ゲーム状態を更新
    if (player) {
      player.x = limitedX;
    }
  };

  // ドラッグ開始
  const handleDragStart = (event) => {
    event.preventDefault();
    gameStateRef.current.isDragging = true;
    handlePlayerMove(event);
  };

  // ドラッグ中
  const handleDragMove = (event) => {
    event.preventDefault();
    const { isDragging } = gameStateRef.current;
    if (isDragging) {
      handlePlayerMove(event);
    }
  };

  // ドラッグ終了
  const handleDragEnd = (event) => {
    event.preventDefault();
    gameStateRef.current.isDragging = false;
  };

  // コンポーネントマウント時にゲーム初期化とイベントリスナー追加
  useEffect(() => {
    initGame();
    const canvas = canvasRef.current;

    // マウスイベントリスナー
    canvas.addEventListener('mousedown', handleDragStart);
    canvas.addEventListener('mousemove', handleDragMove);
    canvas.addEventListener('mouseup', handleDragEnd);
    canvas.addEventListener('mouseleave', handleDragEnd);

    // タッチイベントリスナー
    canvas.addEventListener('touchstart', handleDragStart);
    canvas.addEventListener('touchmove', handleDragMove);
    canvas.addEventListener('touchend', handleDragEnd);

    // クリーンアップ関数
    return () => {
      // アニメーションフレームをキャンセル
      const { animationFrameId } = gameStateRef.current;
      if (animationFrameId) {
        cancelAnimationFrame(animationFrameId);
      }

      canvas.removeEventListener('mousedown', handleDragStart);
      canvas.removeEventListener('mousemove', handleDragMove);
      canvas.removeEventListener('mouseup', handleDragEnd);
      canvas.removeEventListener('mouseleave', handleDragEnd);
      canvas.removeEventListener('touchstart', handleDragStart);
      canvas.removeEventListener('touchmove', handleDragMove);
      canvas.removeEventListener('touchend', handleDragEnd);
    };
  }, []);

  return (
    <div>
      <div className="mb-4">
        <h2 className="text-xl font-bold">クリックシューティングゲーム</h2>
        <p>スコア: {score}</p>
        {gameOver && <p className="text-red-500">ゲームオーバー!クリックでリスタート</p>}
      </div>
      <canvas 
        ref={canvasRef} 
        onClick={createBullet}
        className="border-2 border-gray-300 touch-none"
      />
      <p className="mt-2 text-sm text-gray-600">
        プレイヤーをドラッグまたはタッチして移動、クリックで弾を撃つ
      </p>
    </div>
  );
};

export default ShootingGame;

Codespaces 上で動作確認してみます。

アプリが起動していなければ、ターミナルで

npm run start

を実行して起動します。

「ポート」のタブから、ブラウザの別タブで開いて動作確認ができます。

うまく動いていそうなら、ソース管理からコミット・プッシュします。

GitHub Actions のワークフローが走り、デプロイされるのでスマートフォンでミニアプリを開いて動作確認してみましょう。

うまく動かない・気に入らない点・改善してほしい点があれば ChatGPT に依頼しましょう。

Claude から切り替えた場合は、最初にここまででできた ShootingGame.jsx のコードの中身すべて貼り付け、改善してほしい内容の指示をします。

修正してくれたソースコードを Codespaces にコピーして、ブラウザでの動作確認、コミット・プッシュ、ミニアプリでの動作確認…という繰り返しで完成度を高めていきます。

修正後のコードを以下に掲載します。

import React, { useState, useRef, useEffect } from 'react';

const ShootingGame = () => {
  const canvasRef = useRef(null);
  const [score, setScore] = useState(0);
  const [gameOver, setGameOver] = useState(false);
  
  const gameStateRef = useRef({
    player: null,
    enemies: [],
    bullets: [],
    config: null,
    ctx: null,
    isDragging: false,
    animationFrameId: null
  });

  const initGame = () => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    const config = {
      width: 400,
      height: 600,
      playerSize: 40,
      enemySize: 30,
      bulletSize: 10,
      playerColor: '#4CAF50',
      enemyColor: '#F44336',
      bulletColor: '#2196F3',
    };

    gameStateRef.current = {
      player: { x: config.width / 2, y: config.height - 100 },
      enemies: [],
      bullets: [],
      config: config,
      ctx: ctx,
      isDragging: false,
      animationFrameId: null
    };

    const createEnemy = () => {
      const { enemies, config } = gameStateRef.current;
      const x = Math.random() * (config.width - config.enemySize);
      enemies.push({ x, y: 0 });
    };

    const gameLoop = () => {
      const { player, enemies, bullets, config, ctx } = gameStateRef.current;

      ctx.clearRect(0, 0, config.width, config.height);

      if (Math.random() < 0.03) createEnemy();

      for (let i = bullets.length - 1; i >= 0; i--) {
        for (let j = enemies.length - 1; j >= 0; j--) {
          const bullet = bullets[i];
          const enemy = enemies[j];
          
          if (
            bullet &&
            enemy &&
            bullet.x < enemy.x + config.enemySize &&
            bullet.x + config.bulletSize > enemy.x &&
            bullet.y < enemy.y + config.enemySize &&
            bullet.y + config.bulletSize > enemy.y
          ) {
            enemies.splice(j, 1);
            bullets.splice(i, 1);
            setScore(prevScore => prevScore + 1);
            break;
          }
        }
      }

      for (let i = enemies.length - 1; i >= 0; i--) {
        const enemy = enemies[i];
        enemy.y += 3;
        ctx.fillStyle = config.enemyColor;
        ctx.fillRect(enemy.x, enemy.y, config.enemySize, config.enemySize);

        if (enemy.y > config.height) {
          enemies.splice(i, 1);
        }
      }

      for (let i = bullets.length - 1; i >= 0; i--) {
        const bullet = bullets[i];
        bullet.y -= 5;
        ctx.fillStyle = config.bulletColor;
        ctx.fillRect(bullet.x, bullet.y, config.bulletSize, config.bulletSize);

        if (bullet.y < 0) {
          bullets.splice(i, 1);
        }
      }

      ctx.fillStyle = config.playerColor;
      ctx.fillRect(
        player.x, 
        player.y, 
        config.playerSize, 
        config.playerSize
      );

      const gameOverCheck = enemies.some(enemy => 
        enemy.y + config.enemySize > config.height - 100
      );

      if (gameOverCheck) {
        setGameOver(true);
      } else {
        gameStateRef.current.animationFrameId = requestAnimationFrame(gameLoop);
      }
    };

    canvas.width = config.width;
    canvas.height = config.height;

    gameLoop();
  };

  const createBullet = (event) => {
    event.preventDefault();

    if (gameOver) {
      initGame();
      setGameOver(false);
      setScore(0);
      return;
    }

    const { player, bullets, config } = gameStateRef.current;
    const bulletX = player.x + config.playerSize / 2 - config.bulletSize / 2;
    const bulletY = player.y;
    bullets.push({ x: bulletX, y: bulletY });
  };

  const handlePlayerMove = (event) => {
    event.preventDefault();
    const canvas = canvasRef.current;
    const { player, config } = gameStateRef.current;
    const clientX = event.touches 
      ? event.touches[0].clientX 
      : event.clientX;
    const rect = canvas.getBoundingClientRect();
    const newX = clientX - rect.left - config.playerSize / 2;
    const limitedX = Math.max(0, Math.min(newX, config.width - config.playerSize));
    if (player) {
      player.x = limitedX;
    }
  };

  const handleDragStart = (event) => {
    event.preventDefault();
    gameStateRef.current.isDragging = true;
    handlePlayerMove(event);
  };

  const handleDragMove = (event) => {
    event.preventDefault();
    if (gameStateRef.current.isDragging) {
      handlePlayerMove(event);
    }
  };

  const handleDragEnd = (event) => {
    event.preventDefault();
    gameStateRef.current.isDragging = false;
  };

  useEffect(() => {
    initGame();
    const canvas = canvasRef.current;

    canvas.style.touchAction = 'none';

    canvas.addEventListener('mousedown', handleDragStart);
    canvas.addEventListener('mousemove', handleDragMove);
    canvas.addEventListener('mouseup', handleDragEnd);
    canvas.addEventListener('mouseleave', handleDragEnd);

    canvas.addEventListener('touchstart', (event) => {
      handleDragStart(event);
      createBullet(event);
    });
    canvas.addEventListener('touchmove', handleDragMove);
    canvas.addEventListener('touchend', handleDragEnd);

    canvas.addEventListener('click', createBullet);

    return () => {
      const { animationFrameId } = gameStateRef.current;
      if (animationFrameId) {
        cancelAnimationFrame(animationFrameId);
      }

      canvas.removeEventListener('mousedown', handleDragStart);
      canvas.removeEventListener('mousemove', handleDragMove);
      canvas.removeEventListener('mouseup', handleDragEnd);
      canvas.removeEventListener('mouseleave', handleDragEnd);
      canvas.removeEventListener('touchstart', handleDragStart);
      canvas.removeEventListener('touchmove', handleDragMove);
      canvas.removeEventListener('touchend', handleDragEnd);
      canvas.removeEventListener('click', createBullet);
    };
  }, []);

  return (
    <div>
      <div className="mb-4">
        <h2 className="text-xl font-bold">クリックシューティングゲーム</h2>
        <p>スコア: {score}</p>
        {gameOver && <p className="text-red-500">ゲームオーバー!クリックまたはタップでリスタート</p>}
      </div>
      <canvas 
        ref={canvasRef} 
        className="border-2 border-gray-300"
      />
      <p className="mt-2 text-sm text-gray-600">
        プレイヤーをドラッグまたはタッチして移動、クリックまたはタップで弾を撃つ
      </p>
    </div>
  );
};

export default ShootingGame;

6. アイコンの生成

ここで、ミニアプリのアイコンを生成しておきましょう。

ChatGPT の新しいチャットで、以下のような指示を出します。

シューティングゲームのアイコンを作成してください。

気に入ったものができるまで何度かやり直してもよいです。

アイコンができたら、ダウンロードします。

形式が webp なので、ペイントソフト等で png に変換してください。

このアイコンは、ミニアプリのアイコンとして設定します。

LINE Developers のミニアプリチャネルの「チャネル基本設定」内の「チャネルアイコン」で設定をしてください。

また、このファイルはゲーム側でも使用するので、GitHub リポジトリにも上げておきます。

Codespaces のファイルツリーにドラッグアンドドロップでアップロードすることができます。

特にフォルダには格納せず、リポジトリ直下で大丈夫です。

アップロードしたら、コミット・プッシュをするのを忘れずに。

7. スコアのシェア機能の追加

最後に、スコアのシェア機能を追加してみましょう。

ゲームのコードを確認し、スコアを管理している変数を特定します。

const [score, setScore] = useState(0);

このようなコードがあれば、score を参照すればスコアが取得できます。

次に、ゲームオーバー時に表示される箇所を探します。

コンポーネントの最下部を探しましょう。例えばこのような箇所です。

{gameOver && <p className="text-red-500">ゲームオーバー!クリックでリスタート</p>}

ここを少し変えて、シェアボタンを追加します。

{gameOver && <div>ゲームオーバー!クリックでリスタート<button onClick={handleShare}>シェア!</button></div>}

「シェア!」ボタンをクリックすると handleShare 関数が呼ばれるようになります。

同コンポーネントの最上部に

import liff from '@line/liff'

を追加し、return の上あたりに、以下のように handleShare 関数を追加します。

アイコン画像の URL を指定しているところ(2か所)は、先ほどアップロードしたアイコンの URL に変更する必要があります。

https://raw.githubusercontent.com/アカウント名/リポジトリ名/refs/heads/main/アイコンファイル名.png
  const handleShare = () => {
    if (liff.isApiAvailable("shareTargetPicker")) {
      liff.shareTargetPicker([
        {
          "type": "flex",
          "altText": "シューティングゲームのスコアをシェア!",
          "contents": {
            "type": "bubble",
            "hero": {
              "type": "image",
              "url": "https://raw.githubusercontent.com/himanago/miniapp-handson/refs/heads/main/game_icon.png",
              "size": "full",
              "aspectRatio": "20:13",
              "aspectMode": "cover"
            },
            "body": {
              "type": "box",
              "layout": "vertical",
              "contents": [
                {
                  "type": "box",
                  "layout": "vertical",
                  "contents": [
                    {
                      "type": "text",
                      "text": `シューティングゲームで${score}点をとったよ!`,
                      "size": "lg",
                      "color": "#000000",
                      "weight": "bold",
                      "wrap": true
                    }
                  ],
                  "spacing": "none"
                },
                {
                  "type": "box",
                  "layout": "vertical",
                  "contents": [
                    {
                      "type": "text",
                      "text": "手軽に遊べるミニゲーム",
                      "size": "sm",
                      "color": "#999999",
                      "wrap": true
                    }
                  ],
                  "spacing": "none"
                },
                {
                  "type": "box",
                  "layout": "vertical",
                  "contents": [
                    {
                      "type": "button",
                      "action": {
                        "type": "uri",
                        "label": "遊んでみる!",
                        "uri": `https://miniapp.line.me/${liff.id}`
                      },
                      "style": "primary",
                      "height": "md",
                      "color": "#17c950"
                    },
                    {
                      "type": "button",
                      "action": {
                        "type": "uri",
                        "label": "シェアする",
                        "uri": `https://miniapp.line.me/${liff.id}/share`
                      },
                      "style": "link",
                      "height": "md",
                      "color": "#469fd6"
                    }
                  ],
                  "spacing": "xs",
                  "margin": "lg"
                }
              ],
              "spacing": "md"
            },
            "footer": {
              "type": "box",
              "layout": "vertical",
              "contents": [
                {
                  "type": "separator",
                  "color": "#f0f0f0"
                },
                {
                  "type": "box",
                  "layout": "horizontal",
                  "contents": [
                    {
                      "type": "image",
                      "url": "https://raw.githubusercontent.com/himanago/miniapp-handson/refs/heads/main/game_icon.png",
                      "flex": 1,
                      "gravity": "center"
                    },
                    {
                      "type": "text",
                      "text": "シューティングゲーム",
                      "flex": 19,
                      "size": "xs",
                      "color": "#999999",
                      "weight": "bold",
                      "gravity": "center",
                      "wrap": false
                    },
                    {
                      "type": "image",
                      "url": "https://vos.line-scdn.net/service-notifier/footer_go_btn.png",
                      "flex": 1,
                      "gravity": "center",
                      "size": "xxs",
                      "action": {
                        "type": "uri",
                        "label": "action",
                        "uri": `https://miniapp.line.me/${liff.id}`
                      }
                    }
                  ],
                  "flex": 1,
                  "spacing": "md",
                  "margin": "md"
                }
              ]
            }
          }
        }
      ]).then(function (res) {
        if (res) {
          alert("シェアしました!");
        } else {
          alert("シェアをキャンセルしました。");
        }
      })
      .catch(function (error) {
        alert("エラーが発生しました。");
      });
    }
  };

8. 完成版の動作確認

実際に遊んで、スコアをシェアできるか試してみましょう。

9. おわりに

以上でハンズオンは終了です。お疲れさまでした。

実際にミニアプリを開発する際にも GitHub の機能は便利です。

GitHub Pages を用いた構成ではサーバーサイドのプログラムは持てませんが、クライアントのみで動作するミニゲームアプリであれば対応可能です。

既存の LINE 公式アカウントと連携することで、友だちにシェアしてもらうこともできるので、そのようなユースケースであれば今回のハンズオンの内容はそのまま実践投入もできるかもしれません。

参入のハードルが大幅に下がった LINE ミニアプリを、さまざまな場面に活用できるといいですね。

Discussion

kmkmtkmkmt

先日のハンズオン参加者です!ありがとうございました!

 "uri": `https://miniapp.line.me/${liff.id}/share`

の部分が、urlをベタ打ちしても 404エラーとなってしまいます。
(1度シェア後に、シェアされた後の「シェアする」ボタンを押下するとエラーとなります。)
解決方法何かご存知でしょうか?

(ボタン削除するか、最悪クエリパラメータ付きのURLに遷移させてシェア起動すれば良いかとも思うのですが、もしご存知でしたら🙇)