📚

TypeScript で実装した Robots をリファクタリングしてみた (まだCUI ver.)

2020/11/02に公開

ちょっとは TypeScript やってる感が出たと思うよ…

はじめに

前回の記事で実装した Robots の TypeScript コードがあまりにも…ということでリファクタリングしてみました。

クラス図

  • ちょっと真面目に Robots のクラス設計を考えてみました。
  • MVCパターンを意識して設計しました。
    • Model 相当が Robot クラスでロボットの動きなどのルールを管理するロジックの担当。
    • View 相当が Field クラスでターミナル上に表示する画面に関する処理の担当。
    • Controller 相当が Game クラスでユーザーからの操作を受け付け、Robot, Field クラスとの連携を担当。

robots-cd.png

Robot クラス (Model 相当)

  • 前回は一つの.tsファイルで全部記述してしまいましたが、役割分担をして複数のファイルで Robots ゲームを構成しています。
  • そのために 他のファイルで Robot クラスを利用するときには export を宣言し、他のファイルにあるクラスを参照するには importします。(前回実装時にはそれすら知らなかった・・・)
  • プレイヤー、敵、スクラップを含めて全てのロボットを RobotInfo 型の配列で管理しています。
  • プレイヤーの動作、それに伴う敵の動き、スクラップ化など Robots のルール(ロジック)に関する処理をここに記述します。
robot.ts
// ロボットのタイプ(プレイヤー、敵、スクラップ)
export enum RobotType {
    Player,
    Enemy,
    Scrap
}

// ロボットが動く方向
export enum RobotMove {
    Teleport,
    Wait,
    Left,
    Right,
    Down,
    Up,
    LowerLeft,
    LowerRight,
    UpperLeft,
    UpperRight,
    Unknown
}

// 1体のロボットに関する情報 (座標とロボとのタイプ)
export type RobotInfo = {
    x: number,
    y: number,
    type: RobotType
}

// ロボットの操作に必要な関数をinterfaceで予め定義
export interface RobotRules {
    makeRobots(width: number, height: number, level: number): void;
    canPutRobot(x: number, y: number): boolean;
    canMove(x: number, y: number, width: number, height: number): boolean;
    movePlayer(toward: RobotMove, width: number, height: number): boolean;
    moveEnemey(): boolean;
    wipeOut(): boolean;
    countDeadEnemy(): number;
    countTotalDeadEnemy(level: number): number;
}

// ロボットの管理
export class Robot implements RobotRules {
    robotList: RobotInfo[];

    constructor() {
        this.robotList = [];
    }

    // ロボットの初期配置(プレイヤー、敵同時に行う)
    makeRobots(width: number, height: number, level: number): void {
        this.robotList = [];

        // 0番目は Player
        let x = Math.floor((Math.random() * width) + 1);
        let y = Math.floor((Math.random() * height) + 1);
        this.robotList.push({ x, y, type: RobotType.Player });

        // 1番目から (numOfRobots-1)番目はEnemy
        let count = 0;
        const numOfEnemy = level * 10;
        while (count < numOfEnemy) {
            x = Math.floor((Math.random() * width) + 1);
            y = Math.floor((Math.random() * height) + 1);
            if (!this.canPutRobot(x, y)) {
                // 同じ場所にロボットを置かない
                continue;
            }
            this.robotList.push({ x, y, type: RobotType.Enemy });
            count++;
        }
    }

    // ロボットの配置ができるかチェック
    canPutRobot(x: number, y: number): boolean {
        // tslint:disable-next-line: prefer-for-of
        for (let i = 0; i < this.robotList.length; i++) {
            if (x === this.robotList[i].x && y === this.robotList[i].y) {
                return false;
            }
        }

        return true;
    }

    // プレイヤーロボットが正しく動けるかチェック
    canMove(x: number, y: number, width: number, height: number): boolean {
        // フィールド外に出ないかチェック
        if (x === 0 || y === 0 || x === width + 1 || y === height + 1)
            return false;

        // 移動先にスクラップがあるかチェック
        for (let i = 1; i < this.robotList.length; i++) {
            if (this.robotList[i].x === x && this.robotList[i].y === y && this.robotList[i].type === RobotType.Scrap) {
                return false;
            }
        }

        return true;
    }

    // プレイヤーロボットの移動
    movePlayer(toward: RobotMove, width: number, height: number): boolean {
        let x = this.robotList[0].x;
        let y = this.robotList[0].y;
        switch (toward) {
            case RobotMove.Wait:
                break;
            case RobotMove.Teleport:
                do {
                    x = Math.floor((Math.random() * width) + 1);
                    y = Math.floor((Math.random() * height) + 1);
                } while (!this.canMove(x, y, width, height));
                break;
            case RobotMove.Up:
                y--;
                break;
            case RobotMove.Down:
                y++;
                break;
            case RobotMove.Left:
                x--;
                break;
            case RobotMove.Right:
                x++;
                break;
            case RobotMove.UpperLeft:
                x--;
                y--;
                break;
            case RobotMove.UpperRight:
                x++;
                y--;
                break;
            case RobotMove.LowerLeft:
                x--;
                y++;
                break;
            case RobotMove.LowerRight:
                x++;
                y++;
                break;
            case RobotMove.Unknown:
                return false;
        }
        if (!this.canMove(x, y, width, height)) {
            return false;
        }

        this.robotList[0].x = x;
        this.robotList[0].y = y;

        return true;
    }

    // プレイヤーを動かした後に敵を一マス動かす
    moveEnemey(): boolean {
        for (const item of this.robotList) {
            if (item.type === RobotType.Player || item.type === RobotType.Scrap) {
                continue;
            }
            // プレイヤーの位置に向かうように敵を一マス動かす
            if (this.robotList[0].x === item.x && this.robotList[0].y > item.y) {
                item.y++;
            } else if (this.robotList[0].x === item.x && this.robotList[0].y < item.y) {
                item.y--;
            } else if (this.robotList[0].x > item.x && this.robotList[0].y === item.y) {
                item.x++;
            } else if (this.robotList[0].x < item.x && this.robotList[0].y === item.y) {
                item.x--;
            } else if (this.robotList[0].x < item.x && this.robotList[0].y < item.y) {
                item.x--;
                item.y--;
            } else if (this.robotList[0].x < item.x && this.robotList[0].y > item.y) {
                item.x--;
                item.y++;
            } else if (this.robotList[0].x > item.x && this.robotList[0].y < item.y) {
                item.x++;
                item.y--;
            } else if (this.robotList[0].x > item.x && this.robotList[0].y > item.y) {
                item.x++;
                item.y++;
            }
        }

        // 敵同士が衝突したらスクラップにする
        const length = this.robotList.length
        for (let i = 1; i < length - 1; i++) {
            for (let j = i + 1; j < length; j++) {
                if ((this.robotList[i].x === this.robotList[j].x) && (this.robotList[i].y === this.robotList[j].y)) {
                    this.robotList[i].type = RobotType.Scrap;
                    this.robotList[j].type = RobotType.Scrap;
                }
            }
        }

        // プレイヤーと敵が衝突したらゲームオーバー
        for (let i = 1; i < length; i++) {
            if ((this.robotList[0].x === this.robotList[i].x && this.robotList[0].y === this.robotList[i].y)) {
                return false;
            }
        }

        return true;
    }

    // 全滅チェック
    wipeOut(): boolean {
        for (let i = 1; i < this.robotList.length; i++) {
            if (this.robotList[i].type === RobotType.Enemy) {
                return false;
            }
        }
        return true;
    }

    // 倒した敵の数
    countDeadEnemy(): number {
        const length = this.robotList.length;
        let count = 0;
        for (let i = 1; i < length; i++) {
            if (this.robotList[i].type === RobotType.Scrap) {
                count++;
            }
        }
        return count;
    }

    // 累計で倒した敵の数
    countTotalDeadEnemy(level: number): number {
        let total = 0;
        for (let l = level - 1; l > 0; l--) {
            total += l * 10;
        }
        return total + this.countDeadEnemy();
    }
}

Field クラス (View 相当)

  • ターミナル上にフィールド、ガイド、全てのロボットを表示します。
  • Robot クラスで管理しているロボット配列を参照して、ロボットを画面上に表示しています。
  • フィールドの幅と高さを readonly にしています。
field.ts
import { RobotInfo, RobotType } from "./robot"

// フィールドサイズをreadonlyで宣言
export interface FieldSize {
    readonly width: number,
    readonly height: number,
}

// フィールド表示に必要な関数
export interface FieldMethod {
    printField(): void;
    printGuide(level: number, score: number): void;
    printRobots(robotList: RobotInfo[]): void;
}

// 上記インタフェースを実装したFieldクラス
export class FieldCUI implements FieldSize, FieldMethod {
    readonly width = 60;
    readonly height = 20;

    // tslint:disable-next-line: no-empty
    constructor() {
    }

    printField(): void {
        // tslint:disable-next-line: no-console
        console.clear()
        // top of field
        process.stdout.write("+")
        for (let i = 0; i < this.width; i++) {
            process.stdout.write("-")
        }
        process.stdout.write("+\n")

        // inside of field
        for (let j = 0; j < this.height; j++) {
            process.stdout.write("|")
            for (let i = 0; i < this.width; i++) {
                process.stdout.write(" ")
            }
            process.stdout.write("|\n")
        }

        // bottom of field
        process.stdout.write("+")
        for (let i = 0; i < this.width; i++) {
            process.stdout.write("-")
        }
        process.stdout.write("+")
    }

    printGuide(level: number, score: number): void {
        // tslint:disable-next-line: variable-name
        const cursor_x = this.width + 3
        // tslint:disable-next-line: variable-name
        let cursor_y = 0

        process.stdout.cursorTo(cursor_x, cursor_y)
        process.stdout.write("\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        cursor_y++

        process.stdout.write("Directions:\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("y k u\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write(" \\|/\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("h- -l\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write(" /|\\ \n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("b j n\n\n")

        cursor_y++
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("Commands:\n\n")
        cursor_y++
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("w: wait for end\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("t: teleport\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("q: quit\n\n")

        cursor_y++
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("Legend:\n\n")
        cursor_y++
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("+: robot\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("*: junk heap\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("@: you\n\n")

        cursor_y++
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("Level:" + level + "\n\n")
        process.stdout.cursorTo(cursor_x, cursor_y++)
        process.stdout.write("Score:" + score + "\n\n")
    }

    printRobots(robotList: RobotInfo[]) {
        for (const item of robotList) {
            process.stdout.cursorTo(item.x, item.y)
            if (item.type === RobotType.Player) {
                // put player robot
                process.stdout.write('@')
            } else if (item.type === RobotType.Enemy) {
                // put enemy robots
                process.stdout.write('+')
            } else if (item.type === RobotType.Scrap) {
                // put scrap
                process.stdout.write('*')
            } else {
                ;
            }
        }
    }
}

Game クラス (Controller 相当)

  • ユーザーのキー入力を受け付け、Robot, Field クラスと連携しゲーム全体を管理するクラスです。
  • キー入力用の npm パッケージ(keypress)が必要でした。
game.ts
import { FieldCUI } from './field';
import { Robot, RobotMove } from './robot'

export class Game {
    level: number;
    score: number;

    constructor() {
        this.level = 1;
        this.score = 0;
    }
    // Robots Start
    start(): void {
        const robot = new Robot();
        const fieldCUI = new FieldCUI();
        robot.makeRobots(fieldCUI.width, fieldCUI.height, this.level);
        fieldCUI.printField();
        fieldCUI.printRobots(robot.robotList);
        fieldCUI.printGuide(this.level, this.score);

        // keypressライブラリを読み込む
        const keypress = require('keypress');
        // keypressを標準入力に設定
        keypress(process.stdin);
        process.stdin.on('keypress', (ch: any, key: any) => {
            let toward: RobotMove = RobotMove.Wait;
            switch (ch) {
                case 'y':
                    toward = RobotMove.UpperLeft;
                    break;
                case 'k':
                    toward = RobotMove.Up;
                    break;
                case 'u':
                    // 右上に1マス移動
                    toward = RobotMove.UpperRight;
                    break;
                case 'l':
                    // 右に1マス移動
                    toward = RobotMove.Right;
                    break;
                case 'n':
                    // 右下に1マス移動
                    toward = RobotMove.LowerRight;
                    break;
                case 'j':
                    // 下に1マス移動
                    toward = RobotMove.Down;
                    break;
                case 'b':
                    // 左下に1マス移動
                    toward = RobotMove.LowerLeft;
                    break;
                case 'h':
                    // 左に1マス移動
                    toward = RobotMove.Left;
                    break;
                case 'w':
                    // 待機
                    break;
                case 't':
                    // スクラップ以外にテレポート. 運が悪いと敵の隣にテレポートで即死.
                    toward = RobotMove.Teleport;
                    break;
                case 'q':
                    toward = RobotMove.Unknown;
                    process.stdin.pause();
                    break;
                default:
                    toward = RobotMove.Wait;
            }

            if (robot.movePlayer(toward, fieldCUI.width, fieldCUI.height)) {
                // ゲームオーバーのとき
                if (!robot.moveEnemey()) {
                    process.stdout.write("Game Over...\n");
                    process.stdin.pause();
                } else {
                    // 敵が全滅
                    if (robot.wipeOut()) {
                        robot.makeRobots(fieldCUI.width, fieldCUI.height, ++(this.level));
                    }
                    // ボーナス点を加味したスコア
                    let bonusSum = 0;
                    for (let level = this.level - 1; level > 0; level--) {
                        bonusSum += level * 100;
                    }
                    this.score = robot.countTotalDeadEnemy(this.level) * 10
                        + bonusSum;
                    // 画面表示
                    fieldCUI.printField();
                    fieldCUI.printRobots(robot.robotList);
                    fieldCUI.printGuide(this.level, this.score);
                }
            }
        });
        // プロセス実行中のキー入力を拾うように設定
        process.stdin.setRawMode(true);
        process.stdin.resume();
    }
}

メイン (エントリポイント相当)

  • Game クラスを呼び出しスタートします。
index.ts
import { Game } from './game'

const game = new Game();
game.start();

ソースコード

  • GitHub に公開しました。

動作確認

  • リファクタリングしても正しく動きました。
    robots.gif

おわりに

  • だいぶ TypeScript とお友達になれたと思います。
  • 型駆動開発というべきか、TypeScript の利点が少しづつわかってきた気がします。型宣言で開発効率と安全性を高められると言われている理由がわかってきました。
  • 今度こそ、GUI 化しやすくなったと思います。Robot クラスのところは変えないで、View と Controller をフロントエンドに対応できれば OK でしょう。多分。

Discussion