【風のタクト】海戦ゲームをTypescriptで今風に作ってみた

# 前説
先日、[人生初の作品を、人生初のRustプログラミングで再現してみた](https://qiita.com/Perukii/items/4f95fcd205ac724ffb3d)という記事を読み、自分自身の人生初の作品は何だったのだろうかと思い返した。
入社してから手掛けたものは、いずれも既存のプログラムだし(何より公開できない)、敢えて挙げるなら入社1年目の忘年会で余興のために作ったビンゴマシン[^1]だろうか・・・

いや、それよりも前・・・学生時代に作ったものがあった。
正直なところ、極めて不真面目な学生だった私は、3年次に情報系のゼミに配属されるまでプログラミングというものに触れたことがなかった[^2]。
それ故に担当の教授はさぞ頭を抱えたことだろう。なにせ覚えたての`SQL`を嬉々として本番環境で試し、危うくマスタデータを消し飛ばすような問題児[^3]だ。

**「何でもいいから言語をひとつ覚えて作品を作ってみなさい」**

そんなミッションを課され、とりあえずカッコいい`Web`ページを作ろうと思い立った私[^4]は`HTML`と`CSS`に手を出し、そして最後に`Javascript`を覚えた[^5]。

そしてようやく作りたいものが浮かんだ。
「ゼルダの伝説 風のタクト[^6]」の海戦ゲームである。
![kaisen.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435429/501fc86e-ba37-aaa3-5755-0fae1a1e7c8b.jpeg)

割と有名なゲームなので知っている方もいるかもしれないが、初見の方のためにルールを説明しておくと・・・

- 8×8マスの中に3隻の船[^7]が隠れている
- 3隻の船はそれぞれ3マス、4マス、5マスの長さで、縦もしくは横向きである(＝斜めはない)
- プレイヤーは大砲を撃ち、対象の完全撃沈を目指す
- 大砲は最大24発まで発射可能で、24発以内に完全撃沈すれば勝利

とまあこんな感じのゲームである。
そこで分からないなりに頭を働かせ、上記の言語を駆使してなんとか完成はさせたのだが、このゲームは初級者にはちょっと厳しめのポイントがいくつかある。
ぱっと挙がるだけでも下記の通りである。

- ランダムに生成される3隻の船は盤面内に必ず収まっていなければならない
- 船同士の座標が被ってはならない

このあたりは勘所を押さえていないとドツボにハマりがちで、ご多分に漏れず私もハマった。
**「盤外の新世界へとオンザクルーズする船」**や**「コバンザメの如く他船に重なる不届き者」**が発生し、「世はまさに大海賊時代」の様相を呈していたが、残念なことに全てのバグを改修しきれずに時間切れ。
そう、未完のまま終わってしまったのである。

そこで今回、無念の思いを抱えて電子の海を彷徨う海賊たちの怨念、もといバグ供養の意味も込めて、私が普段愛用している`Typescript`と`React`を使って、ついでに最近かじった`Neumorphism`(ニューモフィズム)とかいうハイカラなデザインもゴリゴリに混ぜ込んでみる。

# 完成品
先に動くものを見た方が分かりがいいと思うので、画面キャプチャを載せる。
完成品は[GitHub Pagesのこちらのページ](https://shutoyamada.github.io/pirate-game/)で公開中(PC推奨。スマホも対応予定)。
<img width="840" alt="スクリーンショット 2020-07-08 23.18.27.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435429/44a37caf-e3d3-9585-1f42-fb24a96d2ac3.png">

いちおう上記で挙げたバグを回避しつつ、なるべくシンプルな作りに仕上げた。

# ソースコード
ソースも[GitHubのこちらのリポジトリ](https://github.com/ShutoYamada/pirate-game)で公開しているので、コアとなる`GameBoard.tsx`と`TargetInfo.ts`のみ掲載。

<details><summary>TargetInfo.ts</summary><div>

```typescript:TargetInfo.ts
/**
 * ターゲットクラス
 */
export default class TargetInfo {
    // 位置情報
    cells : { row : number, col : number }[] = [];
    // 向き(0:up, 1:right, 2:down, 3:left)
    direction : number = -1;
    // 沈没フラグ
    isBroken : boolean = false;
}

/**
 * 指定したマス目内に存在するTargetInfoを生成する
 * @param lines 行数
 */
export const GenerateTargets = (lines : number) : TargetInfo[] => {
    if(lines < 0) return [];
    let result : TargetInfo[] = [];

    // 長さが3,4,5のTargetInfoを生成する
    result.push(GenerateTarget(5,lines, result));
    result.push(GenerateTarget(4,lines, result));
    result.push(GenerateTarget(3,lines, result));

    return result;
}

/**
 * TargetInfoを生成
 * @param length 長さ
 * @param lines 行数
 * @param currentTargets 既に生成されているTargetInfoリスト
 */
export const GenerateTarget = (length : number, lines : number, currentTargets? : TargetInfo[]) : TargetInfo => {
    let result : TargetInfo = new TargetInfo();
    let created : boolean = false;
    currentTargets = currentTargets || [];
    
    while(!created){
        // ランダムに座標と向きを生成する
        const row : number = Math.floor(Math.random()*(lines));
        const col : number = Math.floor(Math.random()*(lines));
        const direction : number = Math.floor(Math.random()*(4));
        result.direction = direction;

        // 先に設定されたTargetInfoの座標と被っているかチェック
        if(currentTargets.length > 0){
            let duplicate : boolean = false;
            switch(direction){
                // up
                case 0:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.col === col && c.row >= row - length)
                    });
                    break;
                // right
                case 1:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.row === row && c.col <= col + length)
                    });
                    break;
                // down
                case 2:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.col === col && c.row <= row + length)
                    });
                    break;
                // left
                case 3:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.row === row && c.col >= col - length)
                    });
                    break;
            }

            if(duplicate) continue;
        }

        // 生成された座標と向きがマス内に完全に収まるかチェック
        switch(direction){
            // up
            case 0:
                if(length <= row+1){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ row : row-i, col })
                    }
                } 
                break;
            // right
            case 1:
                if(col + length <= lines){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ col : col+i, row })
                    }
                } 
                break;
            // down
            case 2: 
                if(row + length <= lines){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ row : row+i, col })
                    }
                } 
                break;
            // left
            case 3:
                if(length <= col+1){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ col : col-i, row })
                    }
                } 
                break;
        }
    }

    return result;
}
```

</div></detail>

<details><summary>GameBoard.tsx</summary><div>

```typescript:GameBoard.tsx
import React from 'react';
import './GameBoard.css';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShip, faTimes, faCertificate, faCrosshairs, faWater } from "@fortawesome/free-solid-svg-icons";
import TargetInfo, { GenerateTargets } from '../modules/TargetInfo';

interface Props {
    lines? : number,
}

interface State {
    selectedCell : { col : number, row : number }[],
    targets : TargetInfo[],
    max : number,
}

class GameBoard extends React.PureComponent<Props, State> {

    constructor(props : Props) {
        super(props);

        this.state = {
            selectedCell : [],
            targets : [],
            max : 24,
        }
    }

    componentDidMount = () => {
        this?.reset();
    }

    /**
     * ゲームリセット
     */
    reset = () => {
        this.setState({
            targets : [...GenerateTargets(8)],
            selectedCell : [],
        })
    }

    /**
     * セル押下時処理
     * @param row 
     * @param col 
     */
    onClickCell = (row : number, col : number) => {
        let targets = Array.from(this.state.targets);
        const max = this.state.max;
        const selectedCell = Array.from(this.state.selectedCell);
        const aliveShips : number = targets.filter(t => !t.isBroken).length
        const isFinished : boolean = selectedCell.length === max || aliveShips === 0;

        if(isFinished) {
            alert('もう一度遊ぶには「リセット」を押してね!!');
            return
        };

        // 既に選択済のマスかチェック
        const findIndex : number = selectedCell.findIndex((c) => c.row === row && c.col === col);
        if(findIndex < 0){
            selectedCell.push({row, col});
        } else {
            return;
        }

        // 選択済のマスから大破したターゲットがいるか探索
        targets.forEach((t) => {
            if(t.isBroken) return;
            const allHit : boolean = selectedCell.filter((c) => {
                return !!(t.cells.find((tc) => tc.row === c.row && tc.col === c.col));
            }).length === t.cells.length;
            // 大破の場合はフラグを立てておく
            t.isBroken = allHit;
        })

        this.setState({
            selectedCell,
            targets
        })
    }

    render = () => {

        const { selectedCell, targets, max } = this.state;
        const aliveShips : number = targets.filter(t => !t.isBroken).length
        const isFinished : boolean = selectedCell.length === max || aliveShips === 0;

        let { lines } = this.props;
        lines = lines || 8;

        let rows : JSX.Element[] = [];

        for(let i = 0; i < lines; i++){

            let cells : JSX.Element[] = [];

            for(let j= 0; j < lines; j++){
                const selected : boolean = selectedCell.some((c) => c.row === i && c.col === j);
                const isTarget : boolean = targets.some((t) => {
                    return !!(t.cells.find((c) => c.row === i && c.col === j));
                });

                // デフォルトでは透明アイコンを表示
                let icon : JSX.Element = (
                    <FontAwesomeIcon className='water' icon={faWater} />
                );

                // もし選択されたマスなら
                if(selected){
                    // ターゲットの有無で表示するアイコンを変更
                    icon = isTarget? (
                        <FontAwesomeIcon className='icon certificate' icon={faCertificate} />
                    ) : (
                        <FontAwesomeIcon className='icon times' icon={faTimes} />
                    )
                }

                cells.push(
                    <button className={`cell ${selected? 'selected' : 'unselected'} ${isFinished && isTarget? 'target' : ''}`} 
                            key={`${i.toString()}_${j.toString()}`} 
                            onClick={()=>{this.onClickCell(i, j)}}>
                        {icon}
                    </button>
                )
            }

            rows.push(
                <div className="row" key={`${i.toString()}`}>
                    {cells}
                </div>
            )
        }

        const result : JSX.Element = isFinished? (
            <div className="status inset">
                    {targets.filter(t => !t.isBroken).length === 0? (
                        <p className='result'>完全勝利！</p>
                    ) : (
                        <p className='result'>残念！また遊んでね</p>
                    )}
            </div>
        ) : null;

        return (
            <div>
                <div className="description inset">
                    <p>下記のマスの中に隠れている海賊船を大砲で撃沈しよう！<br/>
                    海賊船は3隻で、船体の長さはそれぞれ3マス、4マス、5マス。<br/>
                    大砲の弾は最大24発まで発射できます。</p>
                </div>
                {result}
                <div className="status inset"> 
                    <div>
                        <FontAwesomeIcon className='icon certificate' icon={faCertificate} />
                        <p>当たり</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon times' icon={faTimes} />
                        <p>外れ</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon ship' icon={faShip} />
                        <p>{`${targets.filter(t => !t.isBroken).length}/${targets.length}`}</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon crosshairs' icon={faCrosshairs} />
                        <p>{`${max - selectedCell.length}/${max}`}</p>
                    </div>
                </div>
                <div>
                    {rows}
                </div>
                <div className="menu">
                    <button className="neumorphic-btn" onClick={this?.reset} >リセット</button>
                </div>
            </div>
        );
    }
}

export default GameBoard;
```

</div></details>

※駆け足で作ったので、冗長&汚い箇所がありますが、`ver1`として掲載。

## 要素技術紹介

### Typescript
言わずと知れた`AltJS`の筆頭株。
混沌とした`Javascript`に秩序と安寧をもたらすとか、もたらさないとか。
この規模のアプリなら`Javascript`でも全然いけるのだが、より大規模なアプリケーション開発になると静的型付け[^8]が欲しくなってくる。

### React
今をときめく`Javascript`のフレームワーク。
`Vue`やら`Angular`やらと宗教戦争を繰り広げている。
`HTML`と`Javascript`がフュージョンしたような`JSX`や状態管理のフレームワークである`Redux`を用いて多くの初学者を苦しめる[^9]。

ちなみに`Typescript`搭載の`React`プロジェクトを作るには下記の通りにする。

参考：[create-react-appで React + Typescript な環境を構築する](https://qiita.com/sunnyG/items/05c2e9381d6ba2d9fccf)

```shell
create-react-app hoge --typescript
```

### Neummorphism(ニューモフィズム)
これからのトレンドになるとかならないとか言われている`UI`デザイン。
初期の`iOS`でちらほら見受けられた`Skeuomorphism`(スキューモフィズム)と現在幅を聞かせている**マテリアルデザイン**のいいとこ取りをしたような見た目。
その性質上、影を自在に操る能力者[^10]だけが使いこなせるとされる。

参考：[ニューモーフィズム？CSSコピペ実装できる新Webトレンドの参考HTMLスニペット、ツールまとめ](http://photoshopvip.net/120672)

### GitHub Pages
静的な`Web`ページなら何のコストもかけずに公開できる。
学生の時分は、手塩にかけて育てた`HTML`をローカル端末のブラウザで開いて「自分で作った物が動いた！」と大騒ぎしていたが、今では公開まで手軽にできる。

ちなみに`create-react-app`で作ったアプリを公開する場合は、`package.json`に下記を追加して`npm run build`を実行して`push`するだけである(要`GitHub`側設定)。
**※わざわざ`docs`ディレクトリを作っているのは、`GitHub Pages`が`master`ブランチの`docs`を対象とする制約があるため。**

```json:package.json
{
  "scripts": {
    "build": "react-scripts build && mv build docs"
  },
  "homepage": "https://【GitHubユーザ名】.github.io/【リポジトリ名】"
}
```

```shell
npm run build
```

# まとめ
所要時間だが、それなりに見た目を整えて公開するまでだいたい3,4時間といったところだった。
過去の自分の作品を作り直してみると、自身の成長度合いを知ることができ、非常に有意義な時間だったといえる。

ロジック的には大したものではないが、当時の自分が逆立ちしても解決できなかったようなバグをあっさりと解消できたのはなかなかに痛快だった。

また、デザインもなるべく今の時代に即した形にしているため、何年か経った後に同じ物を時々のデザインで作ると、デザイントレンドの変遷が視覚化できて面白いのではないかと思った。

[^1]: これがまた見返してみると酷いソースだった。忘年会後に「稀に-1番が抽選される」というビンゴの常識を覆すバグを含んでいたことが発覚したのは記憶に新しい。
[^2]: 厳密には「プログラミング基礎」という講義を受講していた。最初こそ簡単だと思っていたが、「オブジェクト思考」云々のくだりで考えることを止めた。「車クラスを継承した救急車クラス？走れればなんでもええやん」という安易な思考停止は、数年越しに自分を苦しめることとなる。
[^3]: 教本片手に`UPDATE`文を実行すると、目の前が真っ白になった。結果的に全データの削除フラグを立ててしまっただけで事なきを得たのだが、周りの私を見る目も真っ白になった。
[^4]: 思い立ったというか、この時は`Web`以外のクラサバやスマホアプリ等々のシステムを知らなかった。世の中知らない事だらけである。
[^5]: 独特のグラフィックと広大なフィールド、さらにニテン堂等の奥深いやり込み要素に当時少年だった私は度肝を抜かれた。そして予約特典の「時のオカリナ裏」のあまりの難易度に更に度肝を抜かれた。
[^6]: 今にして思うと、ニュアンスから察するに教授が課した「言語」とは`Java`やら`C#`やらのことだったと思うのだが、きっと違ったのだろう。
[^7]: 正確には「敵艦隊」。`GC`版では「敵艦隊」だが、リマスターされた`Wii U`版では「巨大イカ」に変更されている。背景には倫理的な理由があったのかもしれないが、イカ相手に大砲をぶっ放すのは動物愛護的にはどうなのか。
[^8]: `Javascript`で何かしらを作った事がある人は「`number`だと思って扱っていた変数がいつの間にか`string`になっていた」という経験をしたことがあると思う。酷い時には`undefined`になっていて、原因もとい犯人探しに奔走することも数知れず。そんな理不尽な出来事も無問題。そう`Typescript`ならね。
[^9]: 一度覚えてしまえば何てことはないのだが、覚え始めの頃は古代文明の碑文のように思えた。「`Store`と`State`と`Reducer`がいて、`Action`を`Dispatch`すればいいんだよ」というルー大柴も裸足で逃げ出すような説明をされればそう思うのも当然か。
[^10]: 今回の作品開発にあたって`box-shadow`をしっかり勉強することになったが、未だにリファレンスを見ながらでないと実装がおぼつかない。これをサラッとできるってんだからデザイナーさんってすげえや。