4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-07-08

前説

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

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

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

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

そしてようやく作りたいものが浮かんだ。
「ゼルダの伝説 風のタクト6」の海戦ゲームである。
kaisen.jpg

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

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

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

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

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

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

完成品

先に動くものを見た方が分かりがいいと思うので、画面キャプチャを載せる。
完成品はGitHub Pagesのこちらのページで公開中(PC推奨。スマホも対応予定)。
スクリーンショット 2020-07-08 23.18.27.png

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

ソースコード

ソースもGitHubのこちらのリポジトリで公開しているので、コアとなるGameBoard.tsxTargetInfo.tsのみ掲載。

TargetInfo.ts
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;
}
GameBoard.tsx
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;

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

要素技術紹介

Typescript

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

React

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

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

参考:create-react-appで React + Typescript な環境を構築する

create-react-app hoge --typescript

Neummorphism(ニューモフィズム)

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

参考:ニューモーフィズム?CSSコピペ実装できる新Webトレンドの参考HTMLスニペット、ツールまとめ

GitHub Pages

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

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

package.json
{
  "scripts": {
    "build": "react-scripts build && mv build docs"
  },
  "homepage": "https://【GitHubユーザ名】.github.io/【リポジトリ名】"
}
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. 一度覚えてしまえば何てことはないのだが、覚え始めの頃は古代文明の碑文のように思えた。「StoreStateReducerがいて、ActionDispatchすればいいんだよ」というルー大柴も裸足で逃げ出すような説明をされればそう思うのも当然か。

  10. 今回の作品開発にあたってbox-shadowをしっかり勉強することになったが、未だにリファレンスを見ながらでないと実装がおぼつかない。これをサラッとできるってんだからデザイナーさんってすげえや。

4
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?