旧プログラマーによる日曜プログラミング日記

趣味は作曲、自分で一から作りたがりのユーザIT子会社の旧プログラマーによるリハビリのための日曜プログラミング日記です。

Phaser3+typescriptでSRPGを開発してみる #012 ユニットの移動(2)

前回はTweenを利用してユニット(sprite)を動かすことをやりましたが、今回は、ユニットを選択して移動できる範囲(可動領域と呼称しましょうか)を出すところをやってみたいと思います。

ユニットの移動(2)

可動領域の出し方

可動領域を出すには、

  • ユニットの可動領域(座標)を洗い出す
  • カーソルのようなアイコン(色違いが良いですかね)で座標を塗りつぶす ことが必要になります。

その後、そのユニットの移動先を指定して、Tweenを使って動かすことになります。

可動領域の洗い出し

では早速、可動領域を洗い出したいと思います。

まず、業務要件の整理です。

  • 移動できる先のみ表示する
  • 移動先に味方キャラが居たら、そこには移動できないものとする
  • 移動中に相手キャラが居たら、そこから先には移動できないものとする
  • 山、林、海などの地形により移動に制約を設ける。例えば、騎馬は海には侵入できない、歩行は林に移動するには平地の移動距離の半分しか進めない、飛行は同じ移動力てどこでも行ける 等

ユニットタイプごとの最大移動力の定義

まずはユニットタイプごとの移動力を定義します。この後のことを考え、表を縦横逆に書いてみます。

歩兵 騎馬 重装 飛行
2 3 1 2

歩兵なら最大2歩進めるという意味です。こんなに小さい値だと、マップも小さくしないと、クリアまでに時間がかかってしまいますが、まあ、例ということで。

こういうように実装してみました。

Cell.ts

/**
 * セル(地形)を定義するクラス
 */
export class Cell {
    private _name?: string;
    private _moveCountMap: Map<UnitType, number> = new Map<UnitType, number>();

    /**
     * ユニット種別ごとに必要な移動力をセット
     * @param foot 歩行
     * @param armor 重装
     * @param cavalry 騎馬
     * @param flying 飛行
     */
    public setMoveCount(foot: number, armor: number, cavalry: number, flying: number): void {
        this._moveCountMap.set(UnitType.FOOT, foot);
        this._moveCountMap.set(UnitType.ARMOR, armor);
        this._moveCountMap.set(UnitType.CAVALRY, cavalry);
        this._moveCountMap.set(UnitType.FLYING, flying);
    }

    /**
     * 機動力を取得
     * @param unitType ユニット種別
     * @returns 機動力
     */
    public getMoveCount(unitType: UnitType): number {
        return this._moveCountMap.get(unitType) as number;
    }
}

このCellクラスを、CellManagerでデータ定義・管理します。

尚、ユニットタイプは下記の4つです。

JobClass.ts

export const enum UnitType {
    FOOT = "FOOT",       // 歩兵
    ARMOR = "ARMOR",         // 重装
    CAVALRY = "CAVALRY",     // 騎兵
    FLYING = "FLYING"      // 飛行
}

ユニットタイプごとの地形による移動力の定義

次に、各地形に入れるか?を定義します。 こんな表のイメージです。

地形 歩兵 騎馬 重装 飛行
平原 1 1 1 1
2 99 1 1
99 99 99 1

99は別に9でも良いのですが、最大移動力より大きい数字で、こうすることで侵入不可なことを表しています。

あと、歩兵の林は2としていますが、

  • 1歩目が林だとすると、最大移動力が2なので進めますが、残りは0となるので、実質1歩しか進めません。
  • 1歩目が平原、2歩目が林だとすると、2歩目に来た時点で残移動力が1なので、2必要な林には入れません。

ということを表現できます。

こちらは、CellManagerクラスで定義しました。

CellManager.ts

/**
 * Cellデータを作るクラス
 */
export class CellCreator {

    /** Cellオブジェクトのセット */
    private _cellMap: Map<CellType, Cell> = this.setupCellType();

    /**
     * Cellを取得
     * @param imageNo 
     * @returns 
     */
    public getCell(imageNo: number): Cell {
        var cellType: CellType = this.getCellType(imageNo);
        return this._cellMap.get(cellType);
    }


    /**
     * Cellタイプを初期化
     * @returns Cellタイプを詰めたマップ
     */
    private setupCellType(): Map<CellType, Cell> {

        var createMap: Map<CellType, Cell> = new Map<CellType, Cell>();
        // 海
        var cellSea = new Cell();
        cellSea.name = "海";
        cellSea.displayCui = "〜";
        cellSea.imageNoArray = CellImageManager.IMAGE_INDEX_NO_SEA;
        cellSea.setMoveCount(99, 99, 99, 99);
        createMap.set(CellType.CELL_SEA, cellSea);

        // 草原
        var cellPlane = new Cell();
        cellPlane.name = "草原";
        cellPlane.displayCui = ".";
        cellPlane.imageNoArray = CellImageManager.IMAGE_INDEX_NO_PLANE;
        cellPlane.setMoveCount(1, 1, 1, 1);
        createMap.set(CellType.CELL_PLANE, cellPlane);

        // 林
        var cellForest = new Cell();
        cellForest.name = "林";
        cellForest.displayCui = "44";
        cellForest.imageNoArray = CellImageManager.IMAGE_INDEX_NO_FOREST;
        cellForest.setMoveCount(2, 1, 99, 1);
        createMap.set(CellType.CELL_FOREST, cellForest);

        // 山
        var cellMountain = new Cell();
        cellMountain.name = "山";
        cellMountain.displayCui = "へ";
        cellMountain.imageNoArray = CellImageManager.IMAGE_INDEX_NO_MOUNTAIN;
        cellMountain.setMoveCount(1, 99, 99, 1);
        createMap.set(CellType.CELL_MOUNTAIN, cellMountain);

        // 高山
        var cellRock = new Cell();
        cellRock.name = "高山";
        cellRock.displayCui = "▲";
        cellRock.imageNoArray = CellImageManager.IMAGE_INDEX_NO_ROCK;
        cellRock.setMoveCount(99, 99, 99, 99);
        createMap.set(CellType.CELL_ROCK, cellRock);

        // 城
        var cellCastle = new Cell();
        cellCastle.name = "城";
        cellCastle.displayCui = "城";
        cellCastle.setMoveCount(99, 1, 99, 99);
        createMap.set(CellType.CELL_CASTLE, cellCastle);

        // 砦
        var cellFort = new Cell();
        cellFort.name = "砦";
        cellFort.displayCui = "凸";
        cellFort.imageNoArray = CellImageManager.IMAGE_INDEX_NO_FORT;
        cellFort.setMoveCount(1, 1, 1, 1);
        createMap.set(CellType.CELL_BARRIER, cellFort);

        // 町
        var cellTown = new Cell();
        cellTown.name = "町";
        cellTown.displayCui = "町";
        cellTown.imageNoArray = CellImageManager.IMAGE_INDEX_NO_TOWN;
        cellTown.setMoveCount(1, 1, 1, 1);
        createMap.set(CellType.CELL_TOWN, cellTown);

        // 外壁など侵入不可箇所
        var cellWall = new Cell();
        cellWall.name = "外壁";
        cellWall.displayCui = "✖️";
        cellWall.imageNoArray = CellImageManager.IMAGE_INDEX_NO_WALL;
        cellWall.setMoveCount(99, 99, 99, 99);
        createMap.set(CellType.CELL_WALL, cellWall);

        return createMap;
    }
}

移動可能なセルの洗い出し

ようやく、本題のアルゴリズムである移動可能なセルの洗い出しになります。方針は以下の通りです。

  1. 仮に全てのセルが平原だとした時に、移動力で行けるであろう場所と、その移動パスを全て洗い出す。その際、マップ内に収まることを確認
  2. ルート上に敵キャラが居ないか、移動先に味方キャラが居ないか確認
  3. 一歩ずつ移動力の総和を確認

このやり方は分かりやすいですが、汎用性に欠けます。。とはいえ、小さいマップで少ない移動力だと十分だとも思うので、まずはこれで実装してみたいと思います。GameSceneクラスからGameDataManagerクラスのメソッドを呼び出し、移動可能領域を取得した上で、その座標を透過的な色で塗るという動きになります。

GameScene.ts

    /**
     * 可能移動領域を表示
     * 北:"N"、東:"E"、南:"S"、西:"W"
     * @param allyUnit 味方ユニット
     */
    private showMoveScope(allyUnit: GameUnit): void {

        // 念の為、ターンエンドでないことをチェックをする
        if (allyUnit.turnEnd) return;

        // 機動力を取得する
        var currentX: number = allyUnit.currentX;
        var currentY: number = allyUnit.currentY;

        // 移動可能なパターンを取得
        var canMovePatterns: { pattern: string, toX: number, toY: number }[] = this._gameData.getCanMovePatterns(allyUnit, currentX, currentY);
        if (canMovePatterns === undefined || canMovePatterns.length === 0) {
            return;
        }
        canMovePatterns.forEach((pattern: { pattern: string, toX: number, toY: number }) => {
            var areaSprite: Phaser.GameObjects.Sprite = this.add.sprite(
                GameScene.calPosition(pattern.toX, GameScene.CELL_PX.WIDTH, GameScene.MAP_POSITION.X),
                GameScene.calPosition(pattern.toY, GameScene.CELL_PX.HEIGHT, GameScene.MAP_POSITION.Y),
                GameScene.MOVEMENT_AREA_IMAGE_KEY);
            this._movementAreaSpriteList.push(areaSprite);
        });
    }

GameDataManager.ts

/**
 * ゲームデータの管理クラス
 */
export default class GameDataManager {

    /**
     * ユニット・武器等のデータを管理するJSONファイルを読み込んだ
     * キャッシュを管理するオブジェクト 
     */
    private _cacheManager: CacheManager;

    /** タイトル */
    private _title?: string;
    /** マップのイメージ画像データ(画像Noで管理) */
    private _mapImageData?: number[][];
    /** マップのセルデータ(草原、林、高山、海など) */
    private _mapCellData?: Cell[][];
    /** 味方ユニット */
    private _allyUnits?: GameUnit[];
    /** 敵ユニット */
    private _enemyUnits?: GameUnit[];

    /** マップの幅(横幅) */
    private _mapWidth: number;
    /** マップの高さ(縦幅) */
    private _mapHeight: number;

    /**
     * fromを与えて移動可能な行き先のパターンを返す
     * @param unitType ユニット型
     * @param fromX 出発元x座標
     * @param fromY 出発元y座標
     * @returns object[](pattern string パターン, toX number 到達先x座標, toY number 到達先y座標)
     */
    public getCanMovePatterns(unit: GameUnit, fromX: number, fromY: number): { pattern: string, toX: number, toY: number }[] {

        // 返り値
        let movePatternTowards: { pattern: string, toX: number, toY: number }[] = [];

        // ユニット種別を取得
        const unitType: UnitType = unit.jobClass.unitType;

        // ユニットの最大値
        let unitMoveCount = this.getMoveCount(unitType);

        console.assert(this._mapCellData, "ED0017: mapCellDataが初期化されていません");
        console.assert(this._mapCellData[0][0], "ED0018: mapCellDataが初期化されていません。mapCellData[0][0]が読み込めません");

        // 配列について可能性があるパターンを生成
        // @TODO 今は3歩までにしか対応できていない。このパターンの洗い出しを上手く作りたい
        let allPatterns: string[] = [];
        switch (unitMoveCount) {
            case 3:
                allPatterns.push(
                    "NNN",
                    "NNE", "NEN", "ENN",
                    "NEE", "ENE", "EEN",
                    "EEE",
                    "SSS",
                    "SSE", "SES", "ESS",
                    "SEE", "ESE", "EES",
                    "NNW", "NWN", "WNN",
                    "NWW", "WNW", "WWN",
                    "WWW",
                    "SSW", "SWS", "WSS",
                    "SWW", "WSW", "WWS"
                );
            case 2:
                allPatterns.push("NN", "NE", "NW", "SE", "SW", "SS",
                    "EE", "EN", "WN", "ES", "WS", "WW");
            case 1:
                allPatterns.push("N", "S", "E", "W");
        }

        // 各パターンの順路で移動可能か検証
        allPatterns.forEach((tmpPattern: string) => {

            // セルパターンとして移動可能か確認
            let canMovePattern = this.canMoveCellPattern(unitType, tmpPattern, fromX, fromY);
            if (canMovePattern.canMoveFlag) {

                if (
                    // ターゲット先への移動の中で、敵キャラとぶつからないか確認                
                    this.checkRivalsInThePath(unit, tmpPattern, fromX, fromY) &&
                    // ターゲット先への移動において、エラーがないか確認
                    this.checkCanMoveToTargetCell(unit, canMovePattern.toX, canMovePattern.toY)) {
                    movePatternTowards.push({ pattern: tmpPattern, toX: canMovePattern.toX, toY: canMovePattern.toY });
                }
            }
        })
        return movePatternTowards;
    }

    /**
     * ユニットの通り道の中に敵キャラがいないことを確認
     * @param unit ユニット
     * @param pattern 移動パターン
     * @param fromX 移動元x座標
     * @param fromY 移動元y座標
     * @returns boolean 敵キャラが通り道にいなければtrue
     */
    private checkRivalsInThePath(unit: GameUnit, pattern: string, fromX: number, fromY: number): boolean {

        let existsRivals: boolean = true;

        console.assert(this._mapCellData, "ED0027: mapCellDataが初期化されていません");
        console.assert(this._mapCellData[0][0], "ED0028: mapCellDataが初期化されていません。mapCellData[0][0]が読み込めません");

        // 現在座標(計算中の)
        let currentX: number = fromX;
        let currentY: number = fromY

        // 1歩ずつ行けるかどうかを確認
        let splitMoveTargets: string[] = pattern.split('');
        let sumMoveCount = 0;

        splitMoveTargets.forEach((moveTarget: string) => {

            switch (moveTarget) {
                case "N":
                    currentY--;
                    break;
                case "E":
                    currentX++;
                    break;
                case "S":
                    currentY++;
                    break;
                case "W":
                    currentX--;
                    break;
            }

            // チェック対象となるユニットを取得
            let units: GameUnit[] = (unit.team === Team.ALLY ? this._enemyUnits : this._allyUnits);

            // 相手のユニットが移動経路上に居ない事を確認
            let existsRivalUnits: GameUnit[] = units.filter((enemyUnit: GameUnit) => {
                return enemyUnit.currentX === currentX && enemyUnit.currentY;
            });
            if (existsRivalUnits.length > 0) {
                existsRivals = false;
                return;
            }
        });
        return existsRivals;
    }



    /**
     * ユニットがその位置にたどり着けるかをチェック
     * @param unit ユニット
     * @param targetX 到達先x座標
     * @param targetY 到達先y座標
     */
    public checkCanMoveToTargetCell(unit: GameUnit, targetX: number, targetY: number): boolean {
        // マップの外に出ないか判定する
        var judgeOutOfMap: boolean =
            targetY < this._mapHeight
            && targetY >= 0
            && targetX < this._mapWidth
            && targetX >= 0;
        if (!judgeOutOfMap) {
            return false;
        }

        // 行き先に他のキャラがいない事を確認
        var otherUnits: GameUnit[] = (unit.team === Team.ALLY ? this._allyUnits : this._enemyUnits);
        var existsAllyOtherUnits: GameUnit[]
            = otherUnits.filter((otherUnit: GameUnit) => { return otherUnit.currentX === targetX && otherUnit.currentY === targetY });
        if (existsAllyOtherUnits.length > 0) {
            return false;
        }

        // 以上フィルタに引っかからなければtrueを返す
        return true;
    }

    /**
     * 指定移動パターンで移動可能かをチェック
     * @param unitType ユニット種別
     * @param pattern 移動パターン
     * @param fromX 現在地x座標
     * @param fromY 現在地y座標
     * @returns canMoveFlag boolean 移動可能かどうか
     * @returns toX number 到達地x座標
     * @returns toY number 到達地y座標
     */
    private canMoveCellPattern(unitType: UnitType, pattern: string, fromX: number, fromY: number)
        : { canMoveFlag: boolean, toX: number, toY: number } {

        // 返り値を準備
        let canMoveFlag: boolean = true;

        // ユニットの最大値
        let unitMoveCount = this.getMoveCount(unitType);

        console.assert(this._mapCellData, "ED0017: mapCellDataが初期化されていません");
        console.assert(this._mapCellData[0][0], "ED0018: mapCellDataが初期化されていません。mapCellData[0][0]が読み込めません");

        // 現在座標(計算中の)
        let currentX: number = fromX;
        let currentY: number = fromY

        // 1歩ずつ行けるかどうかを確認
        let splitMoveTargets: string[] = pattern.split('');
        let sumMoveCount = 0;

        splitMoveTargets.forEach((moveTarget: string) => {

            switch (moveTarget) {
                case "N":
                    currentY--;
                    break;
                case "E":
                    currentX++;
                    break;
                case "S":
                    currentY++;
                    break;
                case "W":
                    currentX--;
                    break;
            }

            // 現在値がマップの外に出ていないか確認
            if (currentY >= this._mapHeight || currentX >= this._mapWidth || currentX < 0 || currentY < 0) {
                canMoveFlag = false;
                return;
            }

            // ユニットの最大移動力と、消費移動力(sumMoveCount)を比較
            let targetCell: Cell = this._mapCellData[currentY][currentX];
            sumMoveCount += targetCell.getMoveCount(unitType);
            if (sumMoveCount > unitMoveCount) {
                canMoveFlag = false;
                return;
            }
        });

        // 移動先座標とセットで返す
        return { canMoveFlag: canMoveFlag, toX: (canMoveFlag ? currentX : null), toY: (canMoveFlag ? currentY : null) };
    }

    /**
     * ユニットの機動力を取得
     * @param unitType ユニットタイプ
     * @returns number 機動力
     */
    public getMoveCount(unitType: UnitType): number {
        console.assert(this._cacheManager, "ED0013: CacheManagerが読み込まれていません");
        console.assert(this._cacheManager.jobClassManager, "ED0014: JobClassManagerが事前に読み込まれていません");
        return this._cacheManager.jobClassManager.getMoveCost(unitType);
    }

かなり長くなってしまいましたが、 この結果の完成品が下図になります。

f:id:mtmusic34:20210812224224p:plain

可動領域の表示

騎馬ユニットの移動できる範囲が表示されており、林に入れないことや、味方ユニットのところに行けないことがわかるかと思います。

【ご参考】移動可能なセル洗い出しの汎用アルゴリズム

残移動力を管理しながら、東西南北について残移動力で移動できるかチェックし、移動できれば移動可能Arrayに入れておいて、次の仮移動したセルで再帰的に同じことをチェックするやり方があります。

今度実装してみようと思います。

まとめ

今回は移動可能領域の表示にチャレンジしました。相当ステップ数を使ってしまいました。

今度はユニットの移動にもチャレンジしてみます。

過去の日記

タイトル 記載内容
#001 プロローグ Phaser3とは
#002 環境設定 インストール・ビルド環境設定
#003 はじめてのPhaser3 初回稼働・キャンバス表示
#004 マップ作成 タイルセットの読込、画面手動作成、Sceneクラスのライフサイクル
#005 Tiledの利用 Tiledによる画面マップJSON作成、マルチレイヤーの背景画面
#006 JSONファイルの読込 データのpreload
#007 ファイルのキャッシュ ゲームデータの管理・シングルトンパターン
#008 データモデル(ユニット編) SRPGに必要なデータ・管理方法
#009 カーソルの移動 キー受け付け、Spriteの活用
#010 各メソッドのコールと状態遷移 Sceneメソッドのライフサイクル・デバッグモードの作り方
#011 ユニットの移動(1) Tweenオブジェクト、味方ユニット移動