ペライチでフラッシュ暗算を作る

## はじめに

<iframe width="560" height="315" src="https://www.youtube.com/embed/scec9222sU8?si=ZwMmj_aOqYVeTGzI" 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>

みなさんはフラッシュ暗算好きですか？  
フラッシュ暗算はこんな感じで、連続で表示される数を足すだけのシンプルなゲームです。だけど奥がものすごく深いです。

[フラッシュ暗算](https://www.flash-anzan.com/aboutflashanzan.html) はそろばんをやっている人の間で行われていて、そろばんの全国大会に競技として取り込まれたり、ギネス世界記録があったりなど、非常に極めがいのあるものとして親しまれています。  
テレビなどで紹介されることもあり、近年ではスマートフォンで数々の脳トレアプリにも取り入れられるなど、ありがたいことに一般の方々にもこのフラッシュ暗算が知られるようになりました。

今回はそのフラッシュ暗算をブラウザ上でシンプルに作ってみよう、という企画です。

## フラッシュ暗算の出題要素

フォントサイズや色の要素を除き、出題要素には以下のようなものがあります。

### 桁数

桁数は、表示される数の桁数です。2 桁なら 25 や 80 などが表示されます。

### 口数

口数は、1 問で表示する数の個数です。3 口なら数が 3 つ順番に表示され、それらを足せばよいということです。

### 秒数

秒数には様々な流派がありますが、今回は業界標準 ~~とされている~~ により近い「1 口目表示～最終口消画」を採用します。  
要するに、口数が 3 口なら「1 つ目の数が表示されてから 3 つ目の数が消えるまで」の時間のことです。  

ここまで 3 つの要素をまとめて、2 桁の数 3 つを 3 秒かけて表示することは「2 桁 3 口 3 秒」、3 桁の数 5 つを 5 秒で表示することは「3 桁 5 口 5 秒」という風に呼ばれます。

:::note info
勘の良い方はお気づきかもしれませんが、「3 口 3 秒」と「5 口 5 秒」では数の切り替わるスピードが異なります。理由については実際に作ってみるところで触れます。
:::

### 1 個あたりの数の表示時間・非表示時間の比

これはそのままの意味なのですが、例を用いて説明します。

例えば、口数を 3 口にした場合、画面の状態は以下のように遷移します。

- （開始）
- 1 個目の数が表示されている
- 何も表示されていない
- 2 個目の数が表示されている
- 何も表示されていない
- 3 個目の数が表示されている
- （終了）

この流れにおける、数が表示されている時間と何も表示されていない時間の比のことを言います。

ここでは 50:50（表示されている時間が 0.5 秒なら、非表示の時間も 0.5 秒であるなど）とします。

:::note warn
この要素は一般的にユーザには操作させません。  
パラメータ自体がわかりにくいということもありますが、同じ秒数で表示・非表示比が異なる場合の優劣が一般的には付けられないのと、ギネス世界記録の条件である「客観的な計測」の面を満たせない場合があると私は考えています。
:::

## 実際に作ってみる

ここからは実際にフラッシュ暗算を作ってみたいと思います。あらかじめ HTML ファイル（真っ白）を用意してください。

### 出題設定のセレクトボックスとスタートボタンを作る

前述のとおり、出題設定には「桁数」「口数」「秒数」があります。今回は「桁数」と「口数」をセレクトボックス、「秒数」をテキストボックスで用意します。加えてスタートボタンを配置します。

各パラメータの仕様は以下の通りとします：

- 桁数の範囲は 3 ～ 1 (桁) で、初期値は 1
- 口数の範囲は 15 ～ 2 (口) で、初期値は 3
- 秒数の範囲は 1 ～ 10 (秒) で、初期値は 5、ステップは 0.1

以下のコードを追加します。

```html
<h2><b>出題設定</b></h2>
<div>
    桁数:
    <select name="digit" id="digit">
        <option value="3">3</option>
        <option value="2">2</option>
        <option value="1" selected>1</option>
    </select>
    桁、

    口数:
    <select name="length" id="length">
        <option value="15">15</option>
        <option value="14">14</option>
        <option value="13">13</option>
        <option value="12">12</option>
        <option value="11">11</option>
        <option value="10">10</option>
        <option value="9">9</option>
        <option value="8">8</option>
        <option value="7">7</option>
        <option value="6">6</option>
        <option value="5">5</option>
        <option value="4">4</option>
        <option value="3" selected>3</option>
        <option value="2">2</option>
        <option value="1">1</option>
    </select>
    口、

    秒数:
    <input id="time" type="number" step="0.1" min="1" max="10" value="5">
    秒

    <button id="btn-start">スタート</button>
</div>
```

ファイルをブラウザで開き、以下のようになっていれば OK です。

![step1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486784/d188f8a0-b7b6-dcb6-8e26-d7fb25963bb7.png)

### 数を表示する場所を作る

先ほどのコードの下に、以下のコードを追加します。

```html
<div id="question-outer"
    style="display: flex; justify-content: center; align-items: center; min-height: 300px; border: solid 1px;">
    <div id="question-inner" style="font-size: xx-large;"></div>
</div>
```

Flexible box を利用して、子要素が中央に配置されるようにします。  
また、表示領域を確保するため `min-height` を設定します。

画面を更新し、以下のようになっていれば OK です。

![step2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486784/f4879845-7122-c0ff-cd6a-c5ac90703986.png)

### 数をランダムに生成する

ここからは JavaScript で数を生成する処理を記述します。  
1 桁の数なら 1 ～ 9、2 桁なら 10 ～ 99 のように、桁数に応じた数をランダムで生成します。

末尾に `script` タグを配置し、以下のコードを追加します。

```js
function randomInt(min, max) {
    return min + Math.floor(Math.random() * (max - min))
}

function getNumbers(digit, length) {
    const numberRange = { min: Math.pow(10, digit - 1), max: Math.pow(10, digit) - 1 }
    const numbers = []
    for (let i = 0; i < length; ++i) {
        numbers.push(randomInt(numberRange.min, numberRange.max))
    }
    return numbers
}
```

桁数と口数を引数に取り、出題する数の配列を返す関数 `getNumbers` を作っています。
与えられた範囲の整数をランダムに 1 個生成する処理は `randomInt` に切り出しています。

画面を更新し、開発者ツールで動作確認してみましょう。

```text
getNumbers(1, 3)
(3) [7, 6, 2]

getNumbers(2, 5)
(5) [69, 47, 80, 57, 88]
```

:::note warn
本来であれば、表示される数の可読性を高めるために

- 1 つの数に同じ数字が含まれない（22 や 799 など）
- 表示される数と次の数で、同じ位に同じ数字が連続しない（28 -> 88 や 163 -> 564 など）

などの条件を加えたり、計算時に発生する繰り上がりの回数を調節するなどして難易度の安定化を図るのですが、処理が非常に複雑になるためここでは省略します。
:::

### 生成された数から答えを算出する

答えの数は、先ほどの `getNumbers` で生成した数をすべて足したものになります。  
`script` タグの末尾に以下のコードを追加します。

```js
function sum(numbers) {
    return numbers.reduce((accumulator, currentValue) => accumulator + currentValue, 0)
}
```

画面を更新し、開発者ツールで動作確認してみましょう。

```text
const numbers = getNumbers(1, 3)
undefined

sum(numbers)
11
```

### 数の表示・非表示タイミングを生成する

最初に「3 口 5 秒」の場合でタイミングの生成処理を考えます。

![flash_timing1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486784/1be86be4-b670-4388-b9eb-732168ad3e93.png)

出題時間内での画面表示は、

- 1 口目表示
- 1 口目消画
- 2 口目表示
- 2 口目消画
- 3 口目表示

の 5 フェーズあります。  
数の表示時間・非表示時間の比が 1:1 であることから、各フェーズの時間は等しいので、1 フェーズあたり 1 秒を割り当てます。  
よって、タイミングは、

- 開始から 0 秒で 1 口目表示
- 開始から 1 秒で 1 口目消画
- 開始から 2 秒で 2 口目表示
- 開始から 3 秒で 2 口目消画
- 開始から 4 秒で 3 口目表示
- 開始から 5 秒で 3 口目消画

となります。

同様に「n 口 t 秒」の場合を考えると、

![flash_timing2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486784/4b99ce33-83c2-195e-d48f-ddfcfe6b5c28.png)

出題時間内での画面表示は、

- 1 口目表示
- 1 口目消画
- 2 口目表示
- （中略）
- n - 1 口目消画
- n 口目表示

の 2n - 1 フェーズあります。  
各フェーズの時間は等しいので、1 フェーズあたり $ t / (2n - 1) $ 秒を割り当てます。
よって、タイミングは、

- 開始から $ 0 $ 秒で 1 口目表示
- 開始から $ t / (2n - 1) $ 秒で 1 口目消画
- 開始から $ 2t / (2n - 1) $ 秒で 2 口目表示
- 開始から （中略）
- 開始から $ (2n - 3)t / (2n - 1) $ 秒で n - 1 口目消画
- 開始から $ (2n - 2)t / (2n - 1) $ 秒で n 口目表示
- 開始から $ (2n - 1)t / (2n - 1) = t $ 秒で n 口目消画

となります。

:::note info
冒頭で「3 口 3 秒」と「5 口 5 秒」では数の切り替わるスピードが異なると述べましたが、上記に従って計算すると、「3 口 3 秒」では 1 フェーズあたり 0.6 秒、「5 口 5 秒」では 1 フェーズあたり 0.55... 秒となり、確かに異なることが分かります。
:::

これらを踏まえて、タイミングの生成処理を `getTiming` という関数に書くと、以下のようになります。  
`script` タグの末尾に追加してください。

```js
function getTiming(length, time, offset) {
    const timing = []
    for (let i = 0; i < length * 2; ++i) {
        timing.push(i * time / (2 * length - 1) + offset)
    }
    return timing
}
```

`offset` は、スタートボタンを押してからの待ち時間を想定しています。

画面を更新し、開発者ツールで動作確認してみましょう。  
なお、時間をミリ秒に変換しています。

```text
getTiming(3, 5000, 1000)
(6) [1000, 2000, 3000, 4000, 5000, 6000]
```

### 生成した数とタイミングに従って、画面に数を表示させる

例えば「1 桁 3 口 5 秒」で「1 -> 7 -> 2」の順に数を表示する場合、

- 0 秒で画面の表示を `"1"` にする
- 1 秒で画面の表示を `""` にする
- 2 秒で画面の表示を `"7"` にする
- 3 秒で画面の表示を `""` にする
- 4 秒で画面の表示を `"2"` にする
- 5 秒で画面の表示を `""` にする

となります。

先にコードを紹介します。以下のコードを `script` タグの末尾に追加してください。

```js
const questionInner = document.getElementById('question-inner')

function getFlashSuite(numbers, timing) {
    const hideNum = () => {
        questionInner.innerText = ""
    }

    const suite = []
    for (let i = 0; i < numbers.length; ++i) {
        const showNum = () => {
            questionInner.innerText = numbers[i]
        }
        suite.push({ fn: showNum, delay: timing[2 * i] })
        suite.push({ fn: hideNum, delay: timing[2 * i + 1] })
    }
    return suite
}

let start, elapsed

function setFlashTimeout(fn, delay) {
    if (start === undefined) {
        start = performance.now()
    }

    const handle = {}
    function loop() {
        elapsed = performance.now() - start
        if (elapsed >= delay) {
            fn()
        } else {
            handle.value = requestAnimationFrame(loop)
        }
    }

    handle.value = requestAnimationFrame(loop)
    return handle
}

function flash() {
    // 出題設定
    const digit = parseInt(document.getElementById('digit').value)
    const length = parseInt(document.getElementById('length').value)
    const time = parseFloat(document.getElementById('time').value) * 1000
    const offset = 1000

    // 出題
    start = undefined
    getFlashSuite(getNumbers(digit, length), getTiming(length, time, offset)).forEach(section => {
        setFlashTimeout(section.fn, section.delay)
    })
}

document.getElementById('btn-start').addEventListener('click', () => {
    flash()
})
```

`getFlashSuite` が、例に挙げたようなタイミングと画面表示内容をセットに持つ配列を生成する関数です。  
各数ごとに表示・非表示の処理を追加しています。

`setFlashTimeout` は `setTimeout` の豪華版と考えてください。  
`setTimeout` は処理を登録した瞬間にタイマーが始まってしまうため、すべての処理を登録し終わる頃には数ミリ秒経過しており、数の表示タイミングに若干の遅延が発生してしまいます。  
そのため、開始時刻をはじめに固定し、[`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) を利用して、予定時間に達したら表示を更新するという処理を PC のリフレッシュレートの範囲内で実行します。

画面を更新し、出題設定が「1 桁 3 口 5 秒」の状態でスタートボタンを押してみましょう。  
1 秒間のインターバルの後、1 秒ごとに数字が表示・非表示を繰り返し、最終的に 3 つの数が表示されれば OK です。

### 答えを入力できるようにする・正誤判定をする

答えの入力は、今回は `window.prompt` を使用して簡易的に行います。
正誤判定の結果は `window.alert` で表示します。

`flash` 関数を以下のように変更してください。

```js
function flash() {
    // 出題設定
    const digit = parseInt(document.getElementById('digit').value)
    const length = parseInt(document.getElementById('length').value)
    const time = parseFloat(document.getElementById('time').value) * 1000
    const offset = 1000
    const promptAfterQuestion = 300

    // 出題
    start = undefined
    const numbers = getNumbers(digit, length)
    getFlashSuite(numbers, getTiming(length, time, offset)).forEach(section => {
        setFlashTimeout(section.fn, section.delay)
    })
    // 正誤判定
    setFlashTimeout(() => {
        const userInput = prompt('答えは？')
        let message
        if (parseInt(userInput) === sum(numbers)) {
            message = '正解！'
        } else {
            message = '不正解…'
        }
        alert(message)
    }, offset + time + promptAfterQuestion)
}
```

画面を更新し、いろいろな設定で遊んでみましょう。

---

お疲れさまでした 🎉

## 完成品

最後に、デモ動画とコード全体を記載しておきます。

<iframe width="560" height="315" src="https://www.youtube.com/embed/DTen5pPEsIs?si=SvgJEiSOj9puo963" 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>

```html:index.html
<h2><b>出題設定</b></h2>
<div>
    桁数:
    <select name="digit" id="digit">
        <option value="3">3</option>
        <option value="2">2</option>
        <option value="1" selected>1</option>
    </select>
    桁、

    口数:
    <select name="length" id="length">
        <option value="15">15</option>
        <option value="14">14</option>
        <option value="13">13</option>
        <option value="12">12</option>
        <option value="11">11</option>
        <option value="10">10</option>
        <option value="9">9</option>
        <option value="8">8</option>
        <option value="7">7</option>
        <option value="6">6</option>
        <option value="5">5</option>
        <option value="4">4</option>
        <option value="3" selected>3</option>
        <option value="2">2</option>
        <option value="1">1</option>
    </select>
    口、

    秒数:
    <input id="time" type="number" step="0.1" min="1" max="10" value="5">
    秒

    <button id="btn-start">スタート</button>
</div>
<div id="question-outer"
    style="display: flex; justify-content: center; align-items: center; min-height: 300px; border: solid 1px;">
    <div id="question-inner" style="font-size: xx-large;"></div>
</div>

<script>
    function randomInt(min, max) {
        return min + Math.floor(Math.random() * (max - min))
    }

    function getNumbers(digit, length) {
        const numberRange = { min: Math.pow(10, digit - 1), max: Math.pow(10, digit) - 1 }
        const numbers = []
        for (let i = 0; i < length; ++i) {
            numbers.push(randomInt(numberRange.min, numberRange.max))
        }
        return numbers
    }

    function sum(numbers) {
        return numbers.reduce((accumulator, currentValue) => accumulator + currentValue, 0)
    }

    function getTiming(length, time, offset) {
        const timing = []
        for (let i = 0; i < length * 2; ++i) {
            timing.push(i * time / (2 * length - 1) + offset)
        }
        return timing
    }

    const questionInner = document.getElementById('question-inner')

    function getFlashSuite(numbers, timing) {
        const hideNum = () => {
            questionInner.innerText = ""
        }

        const suite = []
        for (let i = 0; i < numbers.length; ++i) {
            const showNum = () => {
                questionInner.innerText = numbers[i]
            }
            suite.push({ fn: showNum, delay: timing[2 * i] })
            suite.push({ fn: hideNum, delay: timing[2 * i + 1] })
        }
        return suite
    }

    let start, elapsed

    function setFlashTimeout(fn, delay) {
        if (start === undefined) {
            start = performance.now()
        }

        const handle = {}
        function loop() {
            elapsed = performance.now() - start
            if (elapsed >= delay) {
                fn()
            } else {
                handle.value = requestAnimationFrame(loop)
            }
        }

        handle.value = requestAnimationFrame(loop)
        return handle
    }

    function flash() {
        // 出題設定
        const digit = parseInt(document.getElementById('digit').value)
        const length = parseInt(document.getElementById('length').value)
        const time = parseFloat(document.getElementById('time').value) * 1000
        const offset = 1000
        const promptAfterQuestionInterval = 300
    
        // 出題
        start = undefined
        const numbers = getNumbers(digit, length)
        getFlashSuite(numbers, getTiming(length, time, offset)).forEach(section => {
            setFlashTimeout(section.fn, section.delay)
        })
        // 正誤判定
        setFlashTimeout(() => {
            const userInput = prompt('答えは？')
            let message
            if (parseInt(userInput) === sum(numbers)) {
                message = '正解！'
            } else {
                message = '不正解…'
            }
            alert(message)
        }, offset + time + promptAfterQuestionInterval)
    }

    document.getElementById('btn-start').addEventListener('click', () => {
        flash()
    })
</script>
```

## おわりに

みなさんも脳トレがてらフラッシュ暗算しましょう！ 😄

なお、わたしが練習用に作っているフラッシュ暗算はこちらのリポジトリに公開しています。

https://gitlab.com/technical-study/flash_anzan_web

Windows アプリ版、Android 版はこちら

https://github.com/noritakaIzumi/flash_anzan_web/releases