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

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

Phaser3+typescriptでSRPGを開発してみる #006 JSONファイルの読込

本日はJSONの読み込みです。

Phaser3でのJSONファイルの読み込み方は簡単なのですが、それをtypescriptとしてどう扱うとバグが少ないコーディングができるのか、というところをお伝えしたいです。

これから暫くはメインはtypescriptです。

と言いながら、書いているうちにPhaser3におけるテキスト・画像の表示も書きたくなってしまいました。今度適当に分割するかもです。

JSONファイルの読込

本日のファイル構成

<root>
 |- index.html
 |- props
 |    |- data
 |    |     |- AllyUnits.json
 |- src
 |    |- index.ts
 |    |- model
 |           |- AllyUnit.ts
 |    |- scene
 |           |- Scenes.ts
 |           |- GameScene.ts
 |- dist
      |- index.js

Phaser3での読み込み方

Phaser3でのファイル読み込みは簡単で、既に#005でも似たようなメソッドは出てきています。 this.load.json(【ファイルパス】)です。#005ではtilemapTiledJSON()でしたが、使い方はかなり似ています。

ここで早速注意なのですが、node.jsに内包されているfsモジュールは、Phaser3では使えません。

当たり前のことらしいのですが、私はこのことに気づくのに数日かかりました。。 node.jsはサーバー側で動くものだから、という説明や、セキュリティを考えて禁止してるから、という説明が多いですが、今だとwebpackを使ってnode.jsのライブラリ依存関係を定義すれば、利用できると思います。

ただ、例えfsモジュール等で解決できるとしても、Phaser3を使うのであれば、Phaser3の正規のライブラリを使った方が良いですかね。

余談ですが

ちなみに、node.jsの読み込みを明示的にwebpackの定義ファイル webpack.config.jsに記述しないと、

Uncaught ReferenceError: require is not defined

というエラーが出ます。requireすら見つからないです。それもトランスパイル時には検知せず、実行時エラーです。

だからと言って、npmコマンドでインストールしてnpm install requireとしても解決しないです。。

typescriptでのJSONの読み込み方

読んだJSONをデータとして扱うためには、クラスが必要になります。

JSONとは、JavaScript Object Notationの頭文字で、まさにJavascriptで取り扱いやすくするためのファイルフォーマット形式です。すなわち、Javascriptの記法で取り扱うことができます。

例えば、先ほどから出てきているpackage.jsonの中身、

{
  "scripts": {
    "build" : "webpack --more production"
  }

にアクセスする時には、【JSONを格納している変数】.scripts.buildで取得することができます。

もちろん、Javascriptはこれでいいですが、Typescriptの場合、少し困ります。Typescriptは型を通してプログラムソースの可読性を上げ、IDEVSCodeなどの統合開発環境)使用時に、変数名が間違っていないか?といったチェックを自動的にやってくれたり補間してくれたりする言語です。どこかに型を指定しないと、補間機能や変数名のスペルミスチェック機能が働きません。any型を使うのも少し味気ないというか、それだったらTypescriptの意味がないじゃんという気がします。。

とは言いながら、クラス(class)を作るとなると、それはそれで面倒です。 適当に作るのであればいいのですが、私みたいにJava出身者だと、「変数のカプセル化」というのをしたくなります。

変数のカプセル化

カプセル化とは、クラスの中のメソッドやメンバ変数を、他のクラスから直接変えられないようにするように保護する仕組みのことです。よくある考え方は、メンバ変数をprivate属性で定義して、そのクラスの中しかアクセスできないようにして、これらメンバ変数に値をセットしたり、取得したりするときには、getter/setterと呼ばれるメソッド(getXXX()setXXX(xxx))を使ってアクセスするというものです。

export class Person {
    private _name?:string;
    private _age?: number;

    public get name(): string {
        return this._name;
    }
    public set age(age: number) {
        this._age = age;
    }
}

この場合だと、nameは取得しかできず外から変更できず、ageは外から変更できますが取得できない、となります。

ちなみに、属性名のところに?修飾子が付いていますが、これは、undefined型というメンバ変数が定義されていない場合もOKとみなすという修飾子で、正確に言うと、string | undefinedのUnion型(stringかundefinedのどっちか)という意味になります。これをつけないと、tsconfig.json"strict" : trueの場合は、undefinedの可能性があるため正確な記載じゃないという意味なのか、エラーとして表示されます。普通にメソッド内で初期化しているところがあっても、constructorの中で定義しない限り、エラーとなります。

f:id:mtmusic34:20210619151257p:plain
メンバー変数の初期化なしエラー

classを用意するのか?

ちょっと話が逸れてしまいました。。話を戻すと、

  • Typescriptでは、JSONファイル読み込んだ後、データを取り扱うときに、そのまま(自然体)だと変数名の補間が効かなくて、typoミスを生みやすい
  • かと言って、classをオブジェクト指向っぽくカプセル化を意識して定義するのは面倒。construtorやsetter/getterを作らないといけない
  • 初期値の設定も必要

ということで、classを用意するのもそこそこ違和感がある実装という気がします

インターフェース(interface)とは

ということでTypescriptではinterfaceをデータ格納先の型として使用するのが一番エレガントな気がします。Typescriptでは、Javaと違い定数だけではなく、メンバ変数も定義できます。 COBOLでいうCOPY句みたいな使い方です。 例えば、下記のように定義してみます。

AllyUnit.ts

// 味方ユニットの属性
export interface DefaultAllyUnit {
    id: string                          // ID
    name: string                        // ユニット名
    caption: string;                    // 説明
    jobClassName: string;               // ジョブクラス名
    initStatus: InitialUnitStatus;      // 初期ステータス
}

// 初期値ステータス
export interface InitialUnitStatus {
    level: number;          // レベル
    maxHp: number;          // HP
    attack: number;         // 力
    speed: number;          // 速さ
    defence: number;        // 物理守備力
    skill: number;          // 技
    luck: number;           // 運
}

インターフェースを2つ定義しています。1つ目のDefaultAllyUnitインターフェースの中で、 initStatusという変数を定義していますが、そちらが2つ目のインターフェースであるInitialUnitStatusを指し示しています。

インターフェースは基本的にはpublic属性、ということで、外から取得したりセットすることが可能です。

ちなみに、読み込む対象のJSONファイルはこんな内容です。あえて、インターフェースの変数以外のものも記載してみました(が深い意味はそんなにないです)。

AllyUnits.json

[
    {
        "id" : "A001",
        "name" : "徳川家康",
        "caption" : "三河国岡崎城を本拠地とする大名",
        "jobClassName" : "ロードナイト",
        "initStatus" : {
            "level" : 5,
            "maxHp" : 35,
            "attack" : 14,
            "speed" : 12,
            "skill" : 11,
            "defence": 9
        },
        "classChangeInfo" : {
            "changeFlag": false
        }
    },
    {
        "id" : "A002",
        "name" : "鳥居元忠",
        "caption" : "家康の幼馴染。関ヶ原の戦い直前、伏見城を守備し石田三成に攻められ自刃",
        "jobClassName" : "アーマーナイト",
        "initStatus" : {
            "level" : 3,
            "maxHp" : 33,
            "attack" : 11,
            "speed" : 8,
            "skill" : 7,
            "defence": 5
        },
        "classChangeInfo" : {
            "changeFlag": true,
            "changeJobClassName" : "ジェネラル",
            "changeJobMinLevel": 20
        }
    },
    {
        "id" : "A004",
        "name" : "本多忠勝",
        "caption" : "初陣以来、57戦、生涯無傷だったと言われる四天王の一人。蜻蛉切の持ち主",
        "jobClassName" : "ランスナイト",
        "initStatus" : {
            "level" : 3,
            "maxHp" : 36,
            "attack" : 13,
            "speed" : 4,
            "skill" : 5,
            "defence": 13
        },
        "classChangeInfo" : {
            "changeFlag": true,
            "changeJobClassName": "デュークナイト",
            "changeJobMinLevel": 20
        }
    }
]

JSONをinterfaceを使って読み込む

そして下記のように読み込みます。テキスト表示のついでに、画像表示もやってみてみます。

GameScene.ts

import { DefaultAllyUnit } from "@src/model/AllyUnit";

export default class GameScene extends Phaser.Scene {

    // JSONファイルのPhaser3内の識別子
    private static DATA_JSON_FILE_KEY = "DATA_JSON_FILE_KEY";

    preload() {
        // JSONファイルのロード
        this.load.json(GameScene.DATA_JSON_FILE_KEY, "/props/data/UnitAlly.json");


        // 画像ファイルを読込
        this.load.image(   
            "A001",                         // 顔写真のID
            "/images/units/A001.png"           // 顔写真のURL
        );
        this.load.image("A002", "/images/units/A002.png");
        this.load.image("A004", "/images/units/A004.png");
    }

    create() {
        // 味方ユニットを取得
        var allyUnits:DefaultAllyUnit[] 
            = this.cache.json.get(GameScene.DATA_JSON_FILE_KEY);     // preload()でキャッシュしているJSONファイルのキーを指定

        // ステータス画面のフォントスタイル
        var fontStatusSizeClass = { fontSize: '12px'};

        // 味方ユニットのステータスを表示
        allyUnits.forEach((unit: DefaultAllyUnit, index:number) => {
            var status:string
                = unit.name + "(" + unit.jobClassName + "/"
                       + "Lv: " + unit.initStatus.level + ")";
            this.add.text(
                10,                        // x座標(テキストは左上の座標を指定)
                10 + index * 150,          // y座標(テキストは左上の座標を指定)
                status,                    // 表示するテキスト
                { fontSize: '14px' }       // style
            );
            this.add.text(30,30 + index * 150,"HP :" + unit.initStatus.maxHp, fontStatusSizeClass);
            this.add.text(30,50 + index * 150,"攻撃力:" + unit.initStatus.attack, fontStatusSizeClass);
            this.add.text(30,70 + index * 150,"守備力:" + unit.initStatus.defence, fontStatusSizeClass);

            // 顔写真を表示
            this.add.image(
                220,                       // x座標(画像ファイルは中心の座標を指定)
                10 + (index + 0.5) * 150,  // y座標(画像ファイルは中心の座標を指定)
                unit.id
            );
        });
        });
    }

このようにすると下図の通り表示されます。

f:id:mtmusic34:20210619165552p:plain
味方ユニットのステータスの読み込み

今回もいくつかポイントがあります。

  • 今回の事例では、JSONファイルとインターフェースのキーがしっかり揃っていることを前提で特にチェックをしていませんが、安全を期す(読み込めなくてもエラーが出るよりマシ)と考えるのであれば、事前にundefinedかチェックするのが良いかなと思いました。
var allyUnits:any 
     = this.cache.json.get(GameScene.DATA_JSON_FILE_KEY);
if (allyUnits && allyUnits[0] && (allyUnits[0] as DefaultAllyUnit).name) {
    // 処理
}
  • this.load.json()preload()メソッド中でないとロードできず、create()メソッドでは動きませんでした。もしかしたら、create()メソッドで読み込めるやり方があるかもですが、現状まだ調べられてないです。
  • this.load.image()メソッドも同様で、そのため、各ユニットの顔写真をpreload()メソッドで呼び出しています。ただ、この呼び出し方はファイルが増えるたびにプログラム改修が発生するため、imagesフォルダのpngファイル全部読込むみたいな仕組みを作った方が良いかもですね。。

preload()メソッドの動き(非同期?)

と思いながら、preload()の中で

preload() {

        <<<中略>>>

        var imageFileName: any = this.cache.json.get("IMAGE_FILE_LIST_JSON");
        console.log(imageFileName);
        (imageFileName as object[]).forEach((file: any) => {
            this.load.image(file.id, file.fileName);
        });
}

this.load.image()を呼び出そうとしても失敗します。console.log()JSONの中身を見てみるとundefinedとなります。

どうやら、preload()内部では各ファイルを非同期でロードして、全部ロードが終わったらcreate()に移っているようですね。今度、実際のファイルの中身を見てみないと。

逆に、create()に上記メソッドを書くと画像ファイルのロードができないです。こちらも、this.load.image()を同期化できないか、こちらも確認してみる価値があるかなあとも思いました。

まとめ

ということで、今回はJSONファイルの読み込み方を深掘りしてみました。typescriptのinterfaceの利用だけでなく、image/jsonの読み込み、そのタイミングまで、幅広くテーマを取り扱いました。

Phaser3のソースの中身そのものを追った方が良さそうなことも出てきたので、今度、番外編的な感じでソース解析してみようかと思います。

次回は読み込んだファイルのキャッシュについてご紹介いたします。

mtmusic34.hatenablog.com

過去の日記

タイトル 記載内容
#001 プロローグ Phaser3とは
#002 環境設定 インストール・ビルド環境設定
#003 はじめてのPhaser3 初回稼働・キャンバス表示
#004 マップ作成 タイルセットの読込、画面手動作成、Sceneクラスのライフサイクル
#005 Tiledの利用 Tiledによる画面マップJSON作成、マルチレイヤーの背景画面