ChatGPT with Canvasを利用して、Flutter x Flameのスケルトンを作成してみた

## はじめに
Flutter x Flameを使ってゲームアプリを作る際、効率的にスケルトンを作成できる方法を模索しました。本記事では、ChatGPTのCanvas機能を活用してスケルトンを作成したプロセスを紹介します。

## 目的
Flutter x Flameでゲームアプリを開発する際に、基本的なスケルトンを効率よく作成したいと考えました。スケルトンがあれば、以下のような利点が得られると考えています。
- アプリの具体的なイメージがつかみやすくなる
- 開発チームでの作業分担が容易になる
- 実装に集中できる

## 背景
Flutterに詳しくない私が一からスケルトンを作るより、ChatGPTに頼るほうが効率的ではないかという発想から始まりました。また、ChatGPTを利用すればコード作成のスピードアップができるのではないかと考えました。

## ChatGPTで作成したアプリ画面
以下のアプリ画面をChatGPTに依頼して作成しました。

1. トップ画面
2. ログイン画面
3. ユーザー登録画面
4. ゲーム画面

### 入力プロンプト

```
Flutter x Flame で作成するゲームアプリのスケルトンを作成してください。
* コードは MVVMで記述し、Model, ViewModel, Viewクラスで構成してください。
* 画面は全部で以下の4画面を作成してください。
 * トップ画面
 * ログイン画面
 * ユーザー登録画面
 * ゲーム画面
* 画面遷移は以下のように行われる
 * トップ画面からログイン画面
 * トップ画面からユーザー登録画面
 * ユーザー登録画面からログイン画面
 * ログイン画面からゲーム画面
 * ログイン済みの場合は、起動時にゲーム画面が表示される
* ログイン画面の要素
 * IDとパスワード
* ユーザー登録画面の要素
 * ニックネームとIDとパスワードを登録する
* ゲーム画面には、折りたたみのメニューが右上に配置されている。
 * メニューには、ログアウトが用意されている
```

### 出力プロンプト

```dart
import 'package:flutter/material.dart';
import 'package:flame/game.dart';
import 'package:shared_preferences/shared_preferences.dart';

// Main function to run the app
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  final isLoggedIn = prefs.getBool('isLoggedIn') ?? false;
  runApp(MyApp(isLoggedIn: isLoggedIn));
}

// Main app widget
class MyApp extends StatelessWidget {
  final bool isLoggedIn;

  MyApp({required this.isLoggedIn});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter x Flame Game',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      initialRoute: isLoggedIn ? '/game' : '/',
      routes: {
        '/': (context) => TopScreen(),
        '/login': (context) => LoginScreen(),
        '/register': (context) => RegisterScreen(),
        '/game': (context) => GameScreen(),
      },
    );
  }
}

// Top Screen
class TopScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Top Screen'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => Navigator.pushNamed(context, '/login'),
              child: Text('Login'),
            ),
            ElevatedButton(
              onPressed: () => Navigator.pushNamed(context, '/register'),
              child: Text('Register'),
            ),
          ],
        ),
      ),
    );
  }
}

// Login Screen
class LoginScreen extends StatelessWidget {
  final TextEditingController idController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Login'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: idController,
              decoration: InputDecoration(labelText: 'ID'),
            ),
            TextField(
              controller: passwordController,
              decoration: InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () async {
                final prefs = await SharedPreferences.getInstance();
                await prefs.setBool('isLoggedIn', true);
                Navigator.pushReplacementNamed(context, '/game');
              },
              child: Text('Login'),
            ),
          ],
        ),
      ),
    );
  }
}

// Register Screen
class RegisterScreen extends StatelessWidget {
  final TextEditingController nicknameController = TextEditingController();
  final TextEditingController idController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Register'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: nicknameController,
              decoration: InputDecoration(labelText: 'Nickname'),
            ),
            TextField(
              controller: idController,
              decoration: InputDecoration(labelText: 'ID'),
            ),
            TextField(
              controller: passwordController,
              decoration: InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                Navigator.pushReplacementNamed(context, '/login');
              },
              child: Text('Register'),
            ),
          ],
        ),
      ),
    );
  }
}

// Game Screen
class GameScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Game'),
        actions: [
          PopupMenuButton(
            onSelected: (value) async {
              if (value == 'logout') {
                final prefs = await SharedPreferences.getInstance();
                await prefs.setBool('isLoggedIn', false);
                Navigator.pushReplacementNamed(context, '/');
              }
            },
            itemBuilder: (context) => [
              PopupMenuItem(
                value: 'logout',
                child: Text('Logout'),
              ),
            ],
          ),
        ],
      ),
      body: GameWidget(
        game: MyGame(),
      ),
    );
  }
}

// Game class using Flame
class MyGame extends FlameGame {
  @override
  Future<void> onLoad() async {
    super.onLoad();
    // Add game initialization logic here
  }
}

```

### このコードが動くために行ったこと

#### ライブラリのインストール

* pubspec.yamlに以下を記載
```yaml
dependencies:
  ... 省略
  flame: ^1.22.0
  shared_preferences: ^2.3.3
```
* 以下コマンドを実行
```shell
$ flutter pub get
```

#### 生成したコードをそのまま main.dart にコピペ

## 作成した画面の動作

![screencast-localhost_58997-2024_12_15-21_13_21.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441328/b8b219bf-461e-9197-1550-a4f71edefe10.gif)

この時点で、指示した内容については実現できている。
(デザインについては、Flutterのデフォルトとなっている)

## ChatGPTを利用した感想
まず、手直しなしでコピペするだけで動くコードが生成できることに驚いた。
自身が、Flutterに対する知見が少ないため、このコードが良いのかどうかが判断はできないが、機能を満たしている点で一旦満足。

### メリット
- 短時間でベースとなるコードを生成できた

### デメリット
- 何がしたいのか、どういったものが作りたいのかという具体的な要求を整理して伝えるスキルが必要
- canvasでは複数ファイルを分割して出力する形ではないため、ディレクトリ分割、ファイル分割は手動で行う必要がある

### 注意点
- 生成したコードを、保守性やセキュリティなど機能面以外の観点からレビューするスキルが無いと、ただ動くだけのコードになってしまい、捨てることになると思うので注意が必要

## 今後の展望
今回作成したスケルトンをベースに、さらなる機能追加やデザインの調整を行い、実際のアプリとして完成させたいと思います。また、他の開発者とも共有し、意見を取り入れながらブラッシュアップを進める予定です。

## まとめ
ChatGPTのCanvas機能を利用することで、Flutter x Flameのスケルトン作成が効率的に進められることが分かりました。特に、初学者や時間のない開発者にとって強力なツールになると感じました。ぜひ、みなさんも試してみてください。