【開発プロセス】完成をイメージして動く状態を維持しながら開発してみる話

本記事は、[サムザップ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/sumzap) の12/5の記事です。

# まえふり

おはようございます、fuzyです。
みなさん、普段どんなことを意識して開発していますか?
この質問はだいぶゆるい制約だと思うので、いろいろな回答が出てきそうですね。
｢設計をもとにしている｣とか｢運用をしやすいように｣とか、はたまたプログラムの観点ではなく｢上司の癖にならって｣とか人間関係?な観点もあったりして。
それらをいろいろ組み合わせ、意識して普段の開発を進めているかと思います。

今回は、そんな観点の一つとして｢完成をイメージして動く状態を維持しながら開発してみる｣ということを意識して簡単なアプリを作りながら、メリットだけを押し出す感じでやってみたいなと思います。
(オレは良い面だけで行く!!)

# 完成をイメージして動く状態を維持しながらって?

今回は｢実際にアプリを操作できる状態を維持しながら｣ということにしたいと思います。
(｢動く状態｣というとテスト書いて動かしながら、といったこともあると思います。)
理由はおいおい。

# つくるアプリ

アドベントカレンダーですし、クリスマスっぽいやつにしよう。
ということで、楽しみながらクリスマスソングを練習できるアプリを作っていこうとー思いますっ。

### やりたいこと

```
- 楽しみながらクリスマスソングを練習できる
  - テキストが表示されていてボタンを押すと音がなる、間違えると切られる。
    - 楽しい!
  - クリスマスの曲(音階)が表示されていて、音のボタンを押すと音がなる、合っていると次の音に進み、間違えると切られるし進めない。
    - クリスマスの曲を練習できる。癖になる!楽しい!!
```

# 準備

いきなり曲がひける状態にするのはやはり大変なので、動く状態を意識して3ステップに分けてみる。

## 動く状態を維持するための作業の分解

1. 動く状態を作れる準備をする
2. テキストが表示されていてボタンを押すと音がなる、間違えると切られる状態にする
3. クリスマスの曲(音階)が表示されていて、音のボタンを押すと音がなる、合っていると次の音に進み、間違えると切られるし進めない状態にする

## 各ステップで実施すること

### 1stステップ、動く状態を作れる準備をする

```
- 音を鳴らす方法を見つける
  - 音をならさなければいけないけれど自分はその方法を知らないので確認しておく必要がある
- テキストとボタンが表示ができるようにする
- 鳴らす音を集める
  - フリー音源を集めておく
- 1音階で弾ける、クリスマスソングを見つける
  - 複雑なことは避けたいのと画面に入り切らないだろうから1音階に収めよう
```
これらを組み合わせれば動く状態を最低限作れるであろう。という準備。

UnityじゃないよFlutterでやるよ、ゲーム業界だと馴染みがないですよね。
Flutterかわいいよ。


### 2ndステップ、テキストが表示されていてボタンを押すと音がなる、間違えると切られる状態にする


```
- 画面にドを表示する
- 画面にドが表示されているのでドの音は鳴らせるようにする
- ド以外の間違った音を鳴らそうとすると切られる
```

遊びの要素をいれつつ動く状態。


### 3rdステップ、クリスマスの曲(音階)が表示されていて、音のボタンを押すと音がなる、合っていると次の音に進み、間違えると切られるし進めない状態にする

```
- ドレミファソラシを鳴らせるようにする
- 音階が合っていれば音がなる
- 音階が合っていれば次の音階が表示される
```

合っていると曲がひける。間違っていると切られて体で覚える練習要素。

こんな感じ。
じゃあさっそく作って行く!!

# 開発作業!

## 1stステップ、動く状態を作れる準備をする


### 音を鳴らす方法を見つける
https://flutter.dev/

UnityじゃないよFlutterでやるよ、ゲーム業界だと馴染みがないですよね。
Flutterかわいいよ。

https://pub.dev/packages/soundpool

https://github.com/ukasz123/soundpool/blob/master/soundpool/example/lib/main.dart

Exampleのソースを使って鳴らしてみる。

どうやらsoundpoolというpackageを使うとできる様子

他にも参考にさせていただいたサイト

https://gaprot.jp/2021/09/07/flutter_se/

https://techblog.recochoku.jp/7864

### テキストとボタンが表示ができるようにする

![スクリーンショット 2021-12-04 20.41.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209892/5dca383a-d6ad-50ae-c436-9214dba385bc.png)

起動時のサンプルコードをいじったり

### 鳴らす音を集める

魔王魂さま

https://maou.audio/category/se/se-inst/page/2/

ここからドレミファソラシをダウンロード

OtoLogicさま

https://otologic.jp/free/se/cut01.html

ここから切られる音をダウンロード

### クリスマスソングを見つける

ピアノ塾さま

https://pianojuku.info/free-sheet-jinglebell/

音ジングルベルに決めた!
後半だけなら音足りる

## 2ndステップ、テキストが表示されていてボタンを押すと音がなる、間違えると切られる状態にする

- 画面にドを表示する
- 画面にドが表示されているのでドの音は鳴らせるようにする
- ド以外の間違った音を鳴らそうとすると切られる

```dart
// 抜粋
import 'package:flutter/material.dart';
import 'package:soundpool/soundpool.dart';
import 'package:flutter/services.dart';

class _MyHomePageState extends State<MyHomePage> {
  Soundpool pool = Soundpool.fromOptions();

  int _soundDo = 0;
  int _soundRe = 0;
  int _soundCut = 0;

  @override
  void initState() {
    super.initState();
    loadSe();
  }

  loadSe() async {
    _soundDo =
        await rootBundle.load("assets/do.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundRe =
        await rootBundle.load("assets/re.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundCut =
        await rootBundle.load("assets/cut.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
  }

  void playSe(int soundId, String soundName) {
    // ドなら音ならす
    if ("ド" == soundName) {
      pool.play(soundId);
    } else {
      // ド以外なら切る
      pool.play(_soundCut);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'ド',
            ),
            ElevatedButton(
              onPressed: (() {
                playSe(_soundDo, 'ド');
              }),
              child: const Text("ド"),
            ),
            ElevatedButton(
              onPressed: (() {
                playSe(_soundRe, 'レ');
              }),
              child: const Text("レ"),
            )
          ],
        ),
      ),
    );
  }
}
```

動かしてみる…
<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/7d78GWafv8">pic.twitter.com/7d78GWafv8</a></p>&mdash; fuzy (@fuzy11871881) <a href="https://twitter.com/fuzy11871881/status/1467084667782574082?ref_src=twsrc%5Etfw">December 4, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

｢まだまだ、だけれどこのまま進めてよさそうだ。｣
(androidなので良い感じに録音できないで音割れてるけれど…。)

## 3rdステップ、クリスマスの曲(音階)が表示されていて、音のボタンを押すと音がなる、合っていると次の音に進み、間違えると切られるし進めない状態にする

- ドレミファソラシを鳴らせるようにする
- 音階が合っていれば音がなる
- 音階が合っていれば次の音階が表示される

```dart
// 抜粋
import 'package:flutter/material.dart';
import 'package:soundpool/soundpool.dart';
import 'package:flutter/services.dart';

class _MyHomePageState extends State<MyHomePage> {
  Soundpool pool = Soundpool.fromOptions();

  int _soundDo = 0;
  int _soundRe = 0;
  int _soundMi = 0;
  int _soundFa = 0;
  int _soundSo = 0;
  int _soundRa = 0;
  int _soundSi = 0;
  int _soundCut = 0;

  @override
  void initState() {
    super.initState();
    loadSe();
  }

  // ドレミファソラシを読み込み
  void loadSe() async {
    _soundDo =
        await rootBundle.load("assets/do.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundRe =
        await rootBundle.load("assets/re.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundMi =
        await rootBundle.load("assets/mi.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundFa =
        await rootBundle.load("assets/fa.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundSo =
        await rootBundle.load("assets/so.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundRa =
        await rootBundle.load("assets/ra.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundSi =
        await rootBundle.load("assets/si.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundCut =
        await rootBundle.load("assets/cut.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
  }

  final List<String> noteList = [
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ソ',
    'ド',
    'レ',
    'ミ',
  ];

  late final int _lastIndex = noteList.length;
  int _noteIndex = 0;

  void playSe(int soundId, String soundName) {
    // 正解なら音ならす
    if (noteList[_noteIndex] == soundName) {
      pool.play(soundId);
      // 次に進める
      _noteIndex++;
    } else {
      // 間違った音だと切る
      pool.play(_soundCut);
    }

    // 最後だったらIndex戻す
    if (_lastIndex == _noteIndex) {
      _noteIndex = 0;
    }
  }

  Widget buttonDoremi(int soundId, String soundName) {
    return Expanded(
        child: ElevatedButton(
      onPressed: () => setState(() {
        playSe(soundId, soundName);
      }),
      child: Text(soundName),
    ));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              noteList[_noteIndex],
              style: const TextStyle(fontSize: 50),
            ),
            Flexible(
                child: Row(
              children: [
                // 音分ボタンつくる
                buttonDoremi(_soundDo, "ド"),
                buttonDoremi(_soundRe, "レ"),
                buttonDoremi(_soundMi, "ミ"),
                buttonDoremi(_soundFa, "ファ"),
                buttonDoremi(_soundSo, "ソ"),
                buttonDoremi(_soundRa, "ラ"),
                buttonDoremi(_soundSi, "シ")
              ],
            ))
          ],
        ),
      ),
    );
  }
}

```


動かしてみる…!

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/UtXyLQsRB3">pic.twitter.com/UtXyLQsRB3</a></p>&mdash; fuzy (@fuzy11871881) <a href="https://twitter.com/fuzy11871881/status/1467086550316220417?ref_src=twsrc%5Etfw">December 4, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

｢ふむふむ、なんとなくそれっぽいゾ!｣

# フィードバック〜アップデート

しかし、さわってみると、自分で選曲しているので『ジングルベル』と知っているから、なんとなくリズムをとって操作できるけれど、わからない人にはまぁわからない。よね。
というフィードバックを得えられた。
のでアップデートしてみる。

- 歌詞を載せちゃう
- 音階も並べて表示しちゃう
- タイトルもつけちゃう
- もう色からしてクリスマス!!(ボクの心も頭もハッピーさ!!)

でん!!

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/1UWZzXJaCK">pic.twitter.com/1UWZzXJaCK</a></p>&mdash; fuzy (@fuzy11871881) <a href="https://twitter.com/fuzy11871881/status/1467098892672311301?ref_src=twsrc%5Etfw">December 4, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

｢おお、よりそれっぽい!｣
(って、言っておかないとオチない。)

# まとめ

- アプリを操作できる状態を維持するのは小分けしてみるとやりやすい
- 小分けにして進むのでフィードバックの頻度も増え、アップデートまでの期間も短くなる
- 小分けにして進むので欲しいものだけ集中して実施できる

結果、必要性の高いものだけを早く積み重ねて行ける。
(ハズ)

ということを頭の片隅にでも!!

それでは、明日は月曜日。
アムロは何年たっても押し出してくれやしないけど、次の記事もみたいので12月の間は押し出さないでね。

明日は@RyotoKitajimaさんです!

# おまけ(全ソース)

```dart
import 'package:flutter/material.dart';
import 'package:soundpool/soundpool.dart';
import 'package:flutter/services.dart';
import 'package:material_color_gen/material_color_gen.dart';
import 'dart:async';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'じんぐるべる',
      theme: ThemeData(
        fontFamily: 'KosugiMaru',
        primarySwatch: const Color(0xFF204060).toMaterialColor(),
        textTheme: const TextTheme(bodyText2: TextStyle(color: Colors.white)),
      ),
      home: const MyHomePage(title: "♪じんぐるべる♪"),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  Soundpool pool = Soundpool.fromOptions();

  int _soundDo = 0;
  int _soundRe = 0;
  int _soundMi = 0;
  int _soundFa = 0;
  int _soundSo = 0;
  int _soundRa = 0;
  int _soundSi = 0;
  int _soundCut = 0;
  int _soundVoice = 0;

  @override
  void initState() {
    super.initState();
    loadSe();
  }

  void loadSe() async {
    _soundDo =
        await rootBundle.load("assets/do.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundRe =
        await rootBundle.load("assets/re.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundMi =
        await rootBundle.load("assets/mi.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundFa =
        await rootBundle.load("assets/fa.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundSo =
        await rootBundle.load("assets/so.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundRa =
        await rootBundle.load("assets/ra.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundSi =
        await rootBundle.load("assets/si.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundCut =
        await rootBundle.load("assets/cut.mp3").then((ByteData soundData) {
      return pool.load(soundData);
    });
    _soundVoice = await rootBundle
        .load("assets/536245__cloud-10__ho-ho-ho-merry-christmas.mp3")
        .then((ByteData soundData) {
      return pool.load(soundData);
    });
  }

  final List<String> noteList = [
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ソ',
    'ド',
    'レ',
    'ミ',
    'ファ',
    'ファ',
    'ファ',
    'ファ',
    'ファ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'レ',
    'レ',
    'ミ',
    'レ',
    'ソ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ミ',
    'ソ',
    'ド',
    'レ',
    'ミ',
    'ファ',
    'ファ',
    'ファ',
    'ファ',
    'ファ',
    'ミ',
    'ミ',
    'ミ',
    'ソ',
    'ソ',
    'ファ',
    'レ',
    'ド',
  ];

  late final int _lastIndex = noteList.length;
  int _noteIndex = 0;

  void playSe(int soundId, String soundName) async {
    // 正解なら音ならす
    if (noteList[_noteIndex] == soundName) {
      pool.play(soundId);
      _noteIndex++;
    } else {
      // 間違った音だと切る
      pool.play(_soundCut);
    }

    // 最後だったらIndex戻す
    if (_lastIndex == _noteIndex) {
      _noteIndex = 0;
      await Future.delayed(const Duration(milliseconds: 1500), () {});

      pool.play(_soundVoice);
    }
  }

  Widget buttonDoremi(int soundId, String soundName, Color color) {
    return Expanded(
        child: ElevatedButton(
      onPressed: () => setState(() {
        playSe(_soundId, _soundName);
      }),
      style: ElevatedButton.styleFrom(
          shape: const CircleBorder(), primary: _color),
      child: Text(_soundName,
          style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
    ));
  }

  final List<String> lyrics = [
    'ジングルベル',
    'ジングルベル',
    'すずがなる',
    'すずのリズムに',
    'ひかりの輪が舞う',
    'ジングルベル',
    'ジングルベル',
    'すずがなる',
    '森に林に',
    'ひびきながら'
  ];

  Widget noteBlock(int idx) {
    return Text(
      noteList[idx],
      style: TextStyle(
          fontSize: 20,
          fontWeight: FontWeight.bold,
          color: idx == _noteIndex &&
                  ('ド' == noteList[_noteIndex] ||
                      'ミ' == noteList[_noteIndex] ||
                      'ソ' == noteList[_noteIndex] ||
                      'シ' == noteList[_noteIndex])
              ? Colors.red
              : idx == _noteIndex &&
                      ('レ' == noteList[_noteIndex] ||
                          'ファ' == noteList[_noteIndex] ||
                          'ラ' == noteList[_noteIndex])
                  ? Colors.green
                  : Colors.white),
    );
  }

  Widget noteAndLyricsBlock(
      int startNoteIndex, int endNoteIndex, int lyricsIndex,
      {String parentheses = 'start_end'}) {
    return Column(children: [
      Row(
        children: [
          for (var i = startNoteIndex; i <= endNoteIndex; i++) noteBlock(i)
        ],
      ),
      Row(children: [
        if ('start' == parentheses || 'start_end' == parentheses)
          const Text('(',
              style: TextStyle(
                fontSize: 9,
              )),
        Text(lyrics[lyricsIndex],
            style: const TextStyle(
              fontSize: 9,
            )),
        if ('end' == parentheses || 'start_end' == parentheses)
          const Text(')',
              style: TextStyle(
                fontSize: 9,
              )),
      ]),
    ]);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF002034),
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          children: <Widget>[
            Container(
                decoration: const BoxDecoration(
                  image: DecorationImage(
                      image: AssetImage("assets/christmas_illumination.png"),
                      fit: BoxFit.cover,
                      colorFilter: ColorFilter.mode(
                          Colors.blueGrey, BlendMode.modulate)),
                ),
                padding: const EdgeInsets.all(40),
                child: Wrap(
                  runSpacing: 18,
                  children: [
                    // 1行目
                    Row(children: [
                      Expanded(
                          flex: 1,
                          child: noteAndLyricsBlock(0, 2, 0,
                              parentheses: 'start')),
                      Expanded(
                          flex: 1,
                          child:
                              noteAndLyricsBlock(3, 5, 1, parentheses: 'end')),
                    ]),
                    //2行目
                    noteAndLyricsBlock(6, 10, 2),
                    noteAndLyricsBlock(11, 18, 3),
                    noteAndLyricsBlock(19, 24, 4),
                    Row(children: [
                      Expanded(
                          flex: 1,
                          child: noteAndLyricsBlock(25, 27, 5,
                              parentheses: 'start')),
                      Expanded(
                          flex: 1,
                          child: noteAndLyricsBlock(28, 30, 6,
                              parentheses: 'end')),
                    ]),
                    noteAndLyricsBlock(31, 35, 7),
                    noteAndLyricsBlock(36, 43, 8),
                    noteAndLyricsBlock(44, 48, 9),
                  ],
                )),
            Flexible(
                child: Row(
              children: [
                buttonDoremi(_soundDo, "ド", Colors.red),
                buttonDoremi(_soundRe, "レ", Colors.green),
                buttonDoremi(_soundMi, "ミ", Colors.red),
                buttonDoremi(_soundFa, "ファ", Colors.green),
                buttonDoremi(_soundSo, "ソ", Colors.red),
                buttonDoremi(_soundRa, "ラ", Colors.green),
                buttonDoremi(_soundSi, "シ", Colors.red)
              ],
            ))
          ],
        ),
      ),
    );
  }
}
```

## 最終版で使ってるリソース

いらすとやさま

https://www.irasutoya.com/2014/05/blog-post_9275.html

freesoundさま

https://freesound.org/people/Cloud-10/sounds/536245/

googleさま

https://fonts.google.com/specimen/Kosugi+Maru