JavaScriptで子供用のひらがな学習ゲームを作った話

## 経緯

2年くらい前の話ですが、子供が平仮名をあまり覚えることができなくて、なんとか楽しみながら積極的に学べないかと日曜プログラミングで子供向けの学習ゲームを作成することにしました。

## 全体イメージ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/58dda976-a133-1495-14d4-f08ea4532e89.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/0babcfe7-0862-6191-efd7-e74b52154854.png)


## デザイン

Webデザイナーの妻に依頼しました。
私の方でラフを描きつつ、世界観を伝えてPhotoshopで画面デザインをつくってもらいました。
こういうときに配偶者が同業だと良いですね。

## 出来上がり

本来はiPadからのタッチ操作を想定しているので、マウスでブロックを動かすのがスムーズにできず、ちょっとモタつきましたが、Mac Safari上でのスクリーンキャストです。

※ 音声なし動画

<iframe width="560" height="315" src="https://www.youtube.com/embed/JLVwslKV2oA?si=uhh2gFR92oXGxv6X" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


## 使用した技術や素材

### ゲームフレームワーク「Phaser」

日本語の情報が少ないため、サンプルコードをみながら推測したりライブラリのコードを読み解いて進める必要がありますが、[Phaser](https://phaser.io/)は簡易なブラウザゲームをつくるには大変便利です。
簡易ゲームを搭載したキャンペーンサイトの作成でも使ったことがあるので、こちらをベースに構築しました。

### イラスト素材「いらすとや」

クイズのお題となるイラストをどうするか、妻に描いてもらうことも考えましたが数が多いのと、お題のマスターデータ追加するごとに依頼が必要となるのも避けたいと思いました。

Stable Diffusionで大量に作成しようと考えましたが、どうもイメージ通りの絵になりません。
うまくいくケースもあるのですが、対象物によっては何度やっても想定通りになりません。
「これはもう、描いたほうが早いのでは..」とも考えました。

結局、このときは「[いらすとや](https://www.irasutoya.com/)」を利用することにしました。
絵のテイストも統一感がありますし、お題に対応するイラストも豊富です。

今であれば、ChatGPTのDALL-E3で作成すると子供向けのテイストのイラストも作りやすく良いと思います。

### 音声合成「音読さん」

当初はMacの`say`コマンドから音声ファイルを生成して使おうと考えていましたが、実際にやってみると違和感が強く、いくつかの音声合成技術を試してみました。
その中で[音読さん](https://ondoku3.com/ja/)がいちばん自然で高品質と感じました。

音程とスピードの調整ができること、クレジット表記を行うことで無料利用できるというのも強みです。

https://ondoku3.com/ja/

例えば、お題「ひこうき」の場合の発話例。
土管が文字を吸引するごとに1文字ずつ発話され、そのあと正解であれば「ひこうき」とまとめて発話するようにしています。
この1文字づつの音声データと、お題ごとの音声データを音読さんによって生成しています。

※ 音声あり動画

<iframe width="560" height="315" src="https://www.youtube.com/embed/rKReLm6isEk?si=mE0jQb-pNcsQ2Lv2" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

### フォント

ブロックのフォントにはNotoフォントを用いました。
このときは、PhaserでWebフォントそのまま利用する手段がわからず、下記のようにPHPのコマンドラインプログラムを用いて、ImageMagickからテキスト画像を大量生成しました。

<details><summary>ひらがなPNG生成</summary>

```php
<?php
$kanaList = [
	"あいうえお",
	"かきくけこ",
	"さしすせそ",
	"たちつてと",
	"なにぬねの",
	"はひふへほ",
	"まみむめも",
	"やゆよ",
	"らりるれろ",
	"わをん"
];
$size=200;
$font="Noto_Sans_JP/NotoSansJP-Bold.otf";
$pointsize=120;
$outdir="out";

$index = 0;
foreach ($kanaList as $row) {
	foreach(mb_str_split($row) as $c) {
		$command = "convert -size {$size}x{$size} xc:transparent -fill black -font {$font} -pointsize {$pointsize} -gravity center -annotate +0-5 '{$c}' {$outdir}/hiragana{$index}.png";
		exec($command);
		$index++;
	}
}
```

</details>


とても非効率なやり方でした。本来はWebフォントを普通にPhaser上で利用するのが良いでしょう。

## データ定義

お題やお題以外に出てくる平仮名のデータ定義は下記のようなJSONで持つようにしました。
（`quiz`配下は一部のみ抜粋）

```json
{
   "kanaList": [
      "あいうえお",
      "かきくけこ",
      "さしすせそ",
      "たちつてと",
      "なにぬねの",
      "はひふへほ",
      "まみむめも",
      "やゆよ",
      "らりるれろ",
      "わをん"
   ],
   "quiz": [
      {
         "id": "kame",
         "answer": "かめ",
         "image": "answer/kame.png",
         "sound": "answer/kame.mp3"
      },
      {
         "id": "neko",
         "answer": "ねこ",
         "image": "answer/neko.png",
         "sound": "answer/neko.mp3"
      }
   ]
}
```

## プログラムコード全体像

本当はTypeScriptを用いて読みやすいコードにしたかったのですが、いまいちPhaserでの書き方がわからず手が止まってしまったので、力技でベタッと書いてみました。
この気軽さも日曜プログラミングの良いところです。
業務時間では許されない「とりあえず、うごけばいいや！」の精神でざっと書き上げました。

<details><summary>ライブラリを除くJavaScript全コード</summary>

```javascript
const SCREEN_WIDTH = 1200;
const SCREEN_HEIGHT = 800;
const TOTAL_BLOCK_COUNT = 8;
const TOTAL_QUIZ_COUNT = 5;
const BLOCK_WIDTH = 100;
const EDUCTION_POSITION = [SCREEN_WIDTH / 2 - 460, SCREEN_WIDTH / 2 - 320]

const CONFIG = {
    type: Phaser.AUTO,
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT,
    backgroundColor: "#000000",
    parent: "game",
    scale: {
        mode: Phaser.Scale.FIT,
    },
    physics: {
        default: "matter",
        matter: {
            gravity: {
                y: 1.2,
            },
            // debug: true,
            debugBodyColor: 0xffffff,
        },
    },
    scene: {
        preload: preload,
        create: create,
        update: update,
        pack: { 
          "files": [
            { type: 'json', key: 'data', url: 'data.json' }
          ]
        }
    },
};

new Phaser.Game(CONFIG);

let blockObjects = [];
let guides = [];
let invisibleBlockObjects = [];
let markCorrect;
let markWrong;
let quizImage;
let isVacuum = false;
let timer;
let answer;
let question;
let pluginGlow;

function preload() {
    this.load.plugin('rexglowfilterpipelineplugin', 'https://raw.githubusercontent.com/rexrainbow/phaser3-rex-notes/master/dist/rexglowfilterpipelineplugin.min.js', true);

    this.load.image("background", "./img/background.png");
    this.load.image("bottom", "./img/bottom.png");
    this.load.image("top", "./img/top.png");
    this.load.image("lever01", "./img/lever01.png");
    this.load.image("lever02", "./img/lever02.png");
    this.load.image("monitor", "./img/monitor.png");
    this.load.image("signal", "./img/signal.png");
    this.load.image("block", "./img/block.png");
    this.load.image("guide", "./img/guide.png");
    this.load.image("seat_shadow", "./img/seat_shadow.png");
    this.load.audio('quiz-correct', ["sound/quiz-correct.mp3"]);
    this.load.audio('quiz-wrong', ["sound/quiz-wrong.mp3"]);
    this.load.audio('effect-fit', ["sound/effect-fit.mp3"]);
    this.load.audio('effect-block-hit', ["sound/effect-block-hit.mp3"]);
    this.load.audio('say-good', ["sound/say-good.mp3"]);
    this.load.atlas('flares', 'img/flares.png', 'img/flares.json');

    for (var i = 0; i <= 45; i++) {
        let key = "hiragana" + i;
        this.load.audio(key, ["sound/" + key + ".mp3"]);
        this.load.image(key, "./img/kana/" + key + ".png");
    }

    for (var i = 1; i <= 7; i++) {
        let key = "clear0" + i;
        this.load.image(key, "./img/" + key + ".png");
    }

    this.cache.json.get("data").quiz.forEach((quiz) => {
        this.load.image("quiz-img-" + quiz.id, "./img/" + quiz.image);
        this.load.audio("quiz-audio-" + quiz.id, ["sound/" + quiz.sound]);
    });
}

function create() {
    pluginGlow = this.plugins.get('rexglowfilterpipelineplugin');
    
    let jsonKanaList = this.cache.json.get("data").kanaList.join("").split("");
    let onSeatBlocks;
    let buttonLever;

    this.matter.world.setBounds();
    this.matter.add.mouseSpring();

    this.sound.mute = fal;
    
    // 背景
    this.add.image(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, "background").setScale(0.5);
    this.add.image(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 190, "monitor").setScale(0.5);

    // 出題インジケータ
    this.add.image(SCREEN_WIDTH - 130, 90, "signal").setScale(0.5).setOrigin(1);
    let indicators = [];
    let indicatorColors = [
        0xdc0800,
        0x5cbcb9,
        0xffff00,
        0x3968ff,
        0xff57ff
    ];
    let indicatorAreaWidth = 160;
    let indicatorAreaX = SCREEN_WIDTH - 300;
    // this.add.rectangle(indicatorAreaX, 65, indicatorAreaWidth, 50, 0xdc0701).setOrigin(0, 0.5).setAlpha(0.5);
    let distance = indicatorAreaWidth / TOTAL_QUIZ_COUNT;
    let indicatorWidth = 20;
    for (let i = 0; i < TOTAL_QUIZ_COUNT; i++) {
        let color = indicatorColors[i % indicatorColors.length];
        let indicator = this.add.circle(indicatorAreaX + i * distance + (distance - indicatorWidth) / 2, 65, indicatorWidth / 2, color).setOrigin(0, 0.5).setAlpha(0.2);
        indicators.push(indicator);
    }

    // 吸い込み時のホコリ表現
    let sourceRect = new Phaser.Geom.Rectangle(SCREEN_WIDTH, 0, 1000, SCREEN_HEIGHT);
    let particles = this.add.particles('flares').setDepth(1);
    let emitter = particles.createEmitter({
        frame: { frames: [ '1', '2', '3', '4', '5', '6' ], cycle: true, quantity: 1 },
        x: -400,
        y: -100,
        moveToX: SCREEN_WIDTH / 2 - 340,
        moveToY: SCREEN_HEIGHT / 2 + 20,
        lifespan: 600,
        scale: 0.5,
        quantity: 0,
        alpha: 0.2,
        blendMode: 'ADD',
        emitZone: { source: sourceRect }
    });

    let quizNumber = 0;
    let loadQuiz = () => {
        markCorrect.setVisible(false);
        question = questions.shift();

        // 既存ブロック破棄
        blockObjects.forEach((blockObject) => {
            blockObject.destroy();
        });
        blockObjects = [];
        if (seat) {
            this.matter.world.remove(seat);
        }

        // 全問終了
        if (!question) {
            renderFin();
            return false;
        }
        
        indicators[quizNumber].setAlpha(1);
        quizNumber++;

        // 台座の作成
        let seatX = SCREEN_WIDTH / 2;
        let seatY = SCREEN_HEIGHT / 2 + 120;
        let seatWidth = question.answer.length * BLOCK_WIDTH * 1.35 + BLOCK_WIDTH * 0.25;
        let seatHeight = 35;
        seat = this.matter.add.rectangle(seatX, seatY, seatWidth, seatHeight, {
            isStatic: true,
            onCollideCallback: (obj) => {
                if (obj.bodyB.gameObject) {
                    obj.bodyB.gameObject.setData('onSeat', true);
                    obj.bodyB.gameObject.last.setAlpha(1);
                }
            },
            onCollideEndCallback: (obj) => {
                if (obj.bodyB.gameObject && ! obj.bodyB.gameObject.getData('fixed')) {
                    obj.bodyB.gameObject.setData('onSeat', false);
                    obj.bodyB.gameObject.last.setAlpha(0.6);
                }
            },
        });
        if (seatImage) {
            seatImage.destroy();
        }
        let shadowWidth = 1120;
        let seatBase = this.add.rectangle(0, 0, seatWidth, seatHeight, 0xdc0701);
        let seatTop = this.add.rectangle(0, -15, seatWidth, 5, 0xffffff);
        let seatBottom = this.add.rectangle(0, 15, seatWidth, 5, 0xcccccc);
        let seatShadow = this.add.image(0, seatHeight / 2, "seat_shadow").setScale(seatWidth / shadowWidth, 0.5).setOrigin(0.5, 0);
        seatShadow.setSize(10,10);
        seatImage = this.add.container(seatX, seatY, [seatShadow, seatBase, seatTop, seatBottom] );

        if (quizImage) {
            quizImage.destroy();
        }
        quizImage = this.add.image(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 190, "quiz-img-" + question.id).setScale(0.5);
        var paddingUseKanaAll = jsonKanaList.filter(function (v) {
            return !question.answer.split("").includes(v);
        });

        var paddingCount = TOTAL_BLOCK_COUNT - question.answer.length;
        var paddingUseKana = Phaser.Utils.Array.Shuffle(paddingUseKanaAll).slice(
            0,
            paddingCount
        );
        var useKanaList = paddingUseKana.concat(question.answer.split(""));
        useKanaList = Phaser.Utils.Array.Shuffle(useKanaList);

        guides.forEach((guide, index) => {
            guide.destroy();
        });

        useKanaList.forEach((kana, index) => {
            // 吸着ガイド
            let target;
            let answerIndex = question.answer.split("").indexOf(kana);
            let spaceWidth = BLOCK_WIDTH * 1.35;
            if (answerIndex !== -1) {
                target = {
                    x: SCREEN_WIDTH / 2 - question.answer.length / 2 * spaceWidth + answerIndex * spaceWidth + spaceWidth * 0.5,
                    y: SCREEN_HEIGHT / 2 + 102 - BLOCK_WIDTH / 2
                };
                let guide = this.add.image(target.x, target.y, "guide").setScale(0.5);
                guide.setAlpha(0.7);
                guides.push(guide);

                this.tweens.add({
                    targets: guide,
                    duration: 1500,
                    alpha: 1,
                    yoyo: true,
                    repeat: -1
                });
            }

            // バラバラっと順番に出す
            setTimeout(()=> {
                let kanaIndex = jsonKanaList.indexOf(kana);
                let text = this.add.image(0, 0, "hiragana" + kanaIndex).setScale(0.4).setAlpha(0.6);
                let blockX = Phaser.Math.Between(EDUCTION_POSITION[0], EDUCTION_POSITION[1]);
                let blockY = index * 10;
                let block = this.add.image(0, 0, "block").setScale(0.5);
                let container = this.add.container(blockX, blockY, [block, text] );
                container.setSize(BLOCK_WIDTH, BLOCK_WIDTH);
                this.sound.add("effect-block-hit").play();
                let blockObject = this.matter.add.gameObject(container)
                    .setData('kana', kana)
                    .setBounce(0.1)
                    .setVelocity(0, 10)
                    .setDepth(2)
                    .setActive(false)
                    .setInteractive({ useHandCursor: true })
                    .on("pointerdown", () => {
                        let kanaIndex = jsonKanaList.indexOf(kana);
                        this.sound.add("hiragana" + kanaIndex).play();
                        if ( ! blockObject.getData('onSeat')) {
                            blockObject.setScale(1.1);
                        }
                    })
                    .on("pointerout", () => {
                        blockObject.setScale(1);
                    })
                    .on("pointerup", () => {
                        blockObject.setScale(1);
                    });
                if (target) {
                    blockObject.setData('target', target);
                }
                blockObjects.push(blockObject);
            }, 300 * index);
        });
    };

    let suckBlocks = () => {
        guides.forEach((guide, index) => {
            guide.setVisible(false);
        });
        emitter.setQuantity(1);

        clearTimeout(timer);
        blockQue = onSeatBlocks.concat();
        let suckBlock = () => {
            let blockObject = blockQue.shift();
            if (blockObject) {
                blockObject.setDepth(0);
                blockObject.setData('onSeat', false);
                blockObject.setCollisionCategory(null);
                blockObject.setIgnoreGravity(true);

                // フィルター解除
                if (blockObject.glowTask) {
                    pluginGlow.remove(blockObject);
                    blockObject.glowTask.stop();
                    blockObject.glowTask = null;
                }

                this.tweens.add({
                    targets: blockObject,
                    x: SCREEN_WIDTH / 2 - 370,
                    y: SCREEN_HEIGHT / 2 + 20,
                    duration: 120,
                    onComplete: () => {
                        this.cameras.main.shake(70, 0.01);
                        invisibleBlockObjects.push(blockObject);
                        blockObject.setVisible(false);
                    }
                });
                blockObject.emit('pointerdown');
                timer = setTimeout(suckBlock, 700);
            } else {
                emitter.setQuantity(0);
                this.tweens.add({
                    targets: buttonLever,
                    rotation: 1,
                    duration: 500,
                });
                if (answer === question.answer) {
                    // 正解時は単語用の音声で自然な発話も
                    this.sound.add("quiz-audio-" + question.id).play();
                    setTimeout(() => {
                        this.sound.add("quiz-correct").play();
                        markCorrect.setVisible(true);
                    }, 1000);
                    setTimeout(() => {
                        isVacuum = false;
                        loadQuiz();
                    }, 2000);
                } else {
                    // 吸引されたブロックの排出
                    let ejection = () => {
                        ejectObject = invisibleBlockObjects.shift();
                        if (ejectObject) {
                            // bodyがundefineの謎なオブジェクトがたまに混じるのでその場合は無視
                            if (ejectObject.body) {
                                ejectObject.setVisible(true);
                                ejectObject.setDepth(2);
                                ejectObject.setCollisionCategory(1);
                                ejectObject.setIgnoreGravity(false);
                                ejectObject.setPosition(Phaser.Math.Between(EDUCTION_POSITION[0], EDUCTION_POSITION[1]), 0);
                                this.sound.add("effect-block-hit").play();
                            }
                            setTimeout(ejection, 1000);
                        } else {
                            isVacuum = false;
                            guides.forEach((guide, index) => {
                                guide.setVisible(true);
                            });
                        }
                    };
                    markWrong.setVisible(true);
                    this.cameras.main.shake(100, 0.02);
                    this.sound.add("quiz-wrong").play();
                    setTimeout(() => {
                        markWrong.setVisible(false);
                        ejection();
                    }, 1500);
                }
            }
        };
        timer = setTimeout(suckBlock, 700);
    }

    // 床
    let floorHeight = 20;
    this.matter.add.rectangle(SCREEN_WIDTH / 2, SCREEN_HEIGHT - floorHeight / 2, SCREEN_WIDTH, floorHeight, {
        isStatic: true,
        fill: '#40210f'
    });
    this.add.rectangle(SCREEN_WIDTH / 2, SCREEN_HEIGHT - floorHeight / 2, SCREEN_WIDTH, floorHeight, 0x40210f).setDepth(2);
    this.add.rectangle(SCREEN_WIDTH / 2, SCREEN_HEIGHT - floorHeight, SCREEN_WIDTH, 5, 0x895a41).setOrigin(0.5, 0).setDepth(2);

    // 上部土管
    this.add.image(SCREEN_WIDTH / 2 - 300, -230, "top").setScale(0.5).setOrigin(1, 0).setDepth(3);
    
    this.matter.add.rectangle(SCREEN_WIDTH / 2 - 470, 40, 10, 80, {
        isStatic: true,
    });
    this.matter.add.rectangle(SCREEN_WIDTH / 2 - 310, 40, 10, 80, {
        isStatic: true,
    });

    // 下部土管
    this.add.image(SCREEN_WIDTH / 2 - 300, SCREEN_HEIGHT / 2 - 70, "bottom").setScale(0.5).setOrigin(1, 0).setDepth(1);

    let questions = Phaser.Utils.Array.Shuffle(this.cache.json.get("data").quiz).slice(0, TOTAL_QUIZ_COUNT);
    let seat;
    let seatImage;

    // 正解・不正解マーク
    markCorrect = this.add.circle(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, 250).setStrokeStyle(40, 0xff0000).setDepth(10).setVisible(false);
    let shape1 = this.add.rectangle(0, 0, 40, 400, 0xff0000).setOrigin(0.5).setAngle(45);
    let shape2 = this.add.rectangle(0, 0, 40, 400, 0xff0000).setOrigin(0.5).setAngle(-45);
    markWrong = this.add.container(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, [shape1, shape2]).setDepth(10).setVisible(false);

    // 開始画面
    let start;
    let startBg = this.add.rectangle(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, SCREEN_WIDTH, SCREEN_HEIGHT, 0x000000);
    startBg.setInteractive({ useHandCursor: true }).on('pointerdown',  (event) => {
        start.setVisible(false);
        loadQuiz();
    });
    let startText = this.add.text(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, 'ひらがなラボ', { fontSize: '50px', fill: '#fff' }).setOrigin(0.5);
    let startAnnotationText = this.add.text(SCREEN_WIDTH - 20, SCREEN_HEIGHT - 20, 'ひらがな読み上げ音声は音読さんを使用しています。', { fontSize: '15px', fill: '#fff' }).setOrigin(1);
    start = this.add.container(0, 0, [startBg, startText, startAnnotationText]).setDepth(5); //.setVisible(false);
    // startBg.emit('pointerdown'); // 開発用にSTART画面スキップ

    // クリア画面
    const renderFin = () => {
        let finBg = this.add.rectangle(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, SCREEN_WIDTH, SCREEN_HEIGHT, 0x000000).setAlpha(0);
        let finBalloon = [];
        let balloonDefines = [0, 100, 0, 100, 0, 100, 0];
        balloonDefines.forEach((balloonDefine, i) => {
            let x = (SCREEN_WIDTH - 150) / 7 * ( i + 1) + Phaser.Math.Between(-10, 10);
            let y = SCREEN_HEIGHT + 250;
            let toY = SCREEN_HEIGHT / 2 + balloonDefine + Phaser.Math.Between(-50, 50);
            let balloon = this.add.image(x, y, "clear0" + (i + 1)).setScale(0.5);
            finBalloon.push(balloon);
            this.tweens.add({
                targets: balloon,
                y: toY,
                ease: 'Power1',
                duration: 2000,
                delay: 400 * (i + 1),
                onComplete: () => {
                }
            });
        });
        this.tweens.add({
            targets: finBg,
            alpha: 0.6,
            ease: 'Power1',
            duration: 1000,
            onComplete: () => {
            }
        });
        let fin = this.add.container(0, 0, [finBg].concat(finBalloon)).setDepth(5);
        setTimeout(() => {
            this.sound.add("say-good").play();
        }, 1000);
        finBg.setInteractive({ useHandCursor: true }).on('pointerdown',  (event) => {
            fin.destroy();
            this.scene.restart();
        });
    };

    buttonLever = this.add.image(0, 45, "lever01").setOrigin(0.5, 1).setScale(0.5).setRotation(1);
    let buttonBase = this.add.image(0, 95, "lever02").setScale(0.5);
    let button = this.add.container(SCREEN_WIDTH / 2 + 375, SCREEN_HEIGHT / 2 + 100, [buttonLever, buttonBase] ).setSize(250, 100);
    button.setInteractive({ useHandCursor: true })
        .on("pointerdown", () => {
            if (isVacuum) {
                return true;
            }
            // 台座上のブロックを検出し、なにもない場合は中断
            onSeatBlocks = blockObjects.filter((blockObject) => {
                return blockObject.getData('onSeat');
            });
            if (!onSeatBlocks.length) {
                return false;
            }
            
            // 吸着ブロックを解除
            blockObjects.forEach((blockObject) => {
                blockObject
                    .setStatic(false)
                    .setCollisionCategory(1)
                    .setData('adsorb', false)
                    .setData('fixed', false);
            });

            this.tweens.add({
                targets: buttonLever,
                rotation: -1,
                duration: 500,
                onComplete: () => {
                    onSeatBlocks = blockObjects.filter((blockObject) => {
                        return blockObject.getData('onSeat');
                    });
                    onSeatBlocks.sort((a, b) => a.x - b.x);
                    answer = '';
                    onSeatBlocks.forEach((blockObject) => {
                        answer += blockObject.getData('kana');
                    });
                    setTimeout(suckBlocks, 800);
                }
            });

            isVacuum = true;
        })
        .on("pointerover", () => {
            // button.setStyle({ fill: "#f39c12" })
        })
        .on("pointerout", () => {
            // button.setStyle({ fill: "#FFF" })
        });
}

function update() {
    blockObjects.forEach((blockObject) => {
        // 中のテキストは回転しないように
        blockObject.last.rotation = -blockObject.rotation;

        // 吸着処理
        let targetPosition = blockObject.getData('target');
        if (targetPosition) {
            if ( ! isVacuum 
                && ! blockObject.getData('adsorb') 
                && ! blockObject.getData('fixed') 
                && ! blockObject.isStatic()) {
                if (blockObject.x > targetPosition.x - BLOCK_WIDTH / 2
                    && blockObject.x < targetPosition.x + BLOCK_WIDTH / 2
                    && blockObject.y > targetPosition.y - BLOCK_WIDTH / 2
                    && blockObject.y < targetPosition.y + BLOCK_WIDTH / 2) {
                    blockObject.setData('adsorb', true);
                    blockObject.setData('onSeat', true);
                }
            } else if (blockObject.getData('adsorb')) {
                if (blockObject.x > targetPosition.x) {
                    blockObject.x--;
                } else if (blockObject.x < targetPosition.x) {
                    blockObject.x++;
                }
                // x,yともに誤差範囲の場合はフィット完了させる
                let tolerance = 4;
                let diffX = Math.abs(blockObject.x - targetPosition.x);
                let diffY = Math.abs(blockObject.y - targetPosition.y);
                if (diffX < tolerance && diffY < tolerance) {
                    blockObject
                        .setData('adsorb', false)
                        .setData('fixed', true)
                        .setStatic(true);
                    this.sound.add("effect-fit").play();

                    let pipeline = pluginGlow.add(blockObject);
                    blockObject.glowTask = this.tweens.add({
                        targets: pipeline,
                        intensity: 0.01,
                        ease: 'Linear',
                        duration: 1000,
                        repeat: -1,
                        yoyo: true
                    });
                    setTimeout(()=>{
                        blockObject.x = targetPosition.x;
                        blockObject.y = targetPosition.y;
                        blockObject.rotation = 0;
                    }, 300);
                    // blockObject.setCollisionCategory(null);
                }
            }
        }
    });
}
```

</details>


## 作った中での学び

### 子供のユーザーテストは忖度がなくて直球

粗く実装してみては試してもらってというのを繰り返していましたが、大人と違って推測して、実装者の都合の良い方向に動いてくれない事がとても学びにつながりました。
良くも悪くも、「なにこれ？よくわかんない！」ですぐに匙を投げるのです。

* ブロックが回転しても中の文字の向きはそのままにとにしないと子供には理解が困難
* 細かなフィードバックの表現が重要
* ガイドや吸着の実装がないと難しい
    * せっかく正解のブロックを置いても、他のブロックを隣に置くタイミングでぶつけて落としてしまう等

### ブラウザゲームの難しさ

Bimi Booの幼児向けゲームなど、とても良く出来ているのですが専用アプリをインストールする必要があり、もっと手軽にブラウザでアクセスした瞬間から使えるとよいのにと考えていました。

しかし、ブラウザのフルスクリーン表示で子供に操作させても、ふとした拍子にフルスクリーンが解除されたり別画面になってしまい泣き出すということが多くありました。

課金やアプリストアに並ぶ意義などの理由もあるとおもいますが、この辺の事情もあるのかもしれません。

気が向いたら、ネイティブアプリ化してみようかと思います。