【pixi.js】プレイヤーを描写して操作してみよう【ドット式移動】

javascript

ドット式移動

こんにちは。継続の錬金術士なおキーヌです。

ブログ毎日更新は121日目になります。

【pixi.js】画面にマップチップを表示しよう:PIXI.Sprite編の続きですね。

前回はマップチップとタイトルをうたってる割にGUIフレームの表示までやったので時間がかかってしまいました。

今回はキャラクターをマップ画面に出してキー操作で移動できるようにしましょう。

その前に今回も新たな画像を用意したので前回同様ファイルをダウンロードしてsrcディレクトリの中にD&Dしてださい。

今回のPIXI.JS講座のファイルDLはコチラ

私の講座を最初からやっている人は「【pixi.js】キー入力処理を作って四角を動かしてみよう」で既に矩形を操作していると思いますが、
この時は生javascriptをhtmlに直接記述していたので、Sprite表示までのおさらいも兼ねてTypeScriptに載せ替えをやっていきましょう。

Spriteの応用編!プレイヤーキャラを描写しましょう

前回SpriteマップチップやGUIフレームの角を表示するPIXI.Spriteと、
繰り返し同じ画像を表示するPIXI.extras.SpriteSheetsを使いました。

プレイヤーキャラクターの場合は歩行アニメーションを付けたいですよね。

PIXI.Spriteを使って自力で歩行アニメーションを作ることも可能ですが、
PIXI.JSには既に歩行アニメーションを簡単に使うことが出来る仕組みが作られています。

勉強がてら車輪の再発明をしていい地点ではありますが、今回はGWゲーム作りチャレンジをしているので
ありがたくPIXI.JSのアニメーションの仕組みを利用させてもらいましょう。

PIXI.extras.AnimatedSpriteを使おう

プレイヤーに限らずアニメーションさせたいSpriteは基本的にこのクラスを使っていきましょう。

早速使ってみましょう。

まずはいつも通りクラス変数にプレイヤー用の変数を作成します。

    // プレイヤー用変数
    private PlayerSprite:PIXI.extras.AnimatedSprite = new PIXI.extras.AnimatedSprite([PIXI.Texture.EMPTY]);

ちょっと長くなっていますが、PIXI.extras.AnimatedSprite型にPIXI.extras.AnimatedSpriteクラスのインスタンスを入れています。

長いですね……

初期化しないといけないのですがまだこの時点で画像を読み込んでいないので、
PIXI.jsが用意してくれている空のテクスチャをいったん入れて初期化しておきます。

こうすることでTypeScript側でエラーが出ません。

ローダーとリソースローダーで画像とjsonファイルを読み込もう

追加部分は今回actor01.pngとactor01.jsonだけです。
それ以外は変化していません。

    // PIXIローダー
    private loadingResrouces() {
        // エイリアスで設定したローダーの呼び出し
        this.PIXI_loader
        // addメソッドの引数にロードしたい画像ファイルパスを渡す
        .add(__dirname + "/src/assets/images/games/mapchip/testmap.png")
        .add(__dirname + "/src/assets/images/games/system/system.png")
        .add(__dirname + "/src/assets/images/games/actor/actor01.png")
        .add(__dirname + "/src/json/spritesheets/mapchip/testmap.json")
        .add(__dirname + "/src/json/spritesheets/system/system.json")
        .add(__dirname + "/src/json/spritesheets/actor/actor01.json")
        // チェーンメソッドでプログレス機能を稼働
        .on("progress", (loader, resource)=>{
            console.log("progress: " + loader.progress + "%"); 
        })
        // ロード終了後に実行する関数を指定する
        .load(() => {
            this.spriteSetup();
        })
    }

    // セットアップ関数
    private spriteSetup() {
        // テクスチャを準備する
        let mapchips:PIXI.Spritesheet | undefined = this.PIXI_resources[__dirname + "/src/json/spritesheets/mapchip/testmap.json"].spritesheet;
        let systemGraphics:PIXI.Spritesheet | undefined = this.PIXI_resources[__dirname + "/src/json/spritesheets/system/system.json"].spritesheet;
        let actorGraphics:PIXI.Spritesheet | undefined = this.PIXI_resources[__dirname + "/src/json/spritesheets/actor/actor01.json"].spritesheet;
        if(mapchips !== undefined && systemGraphics !== undefined && actorGraphics !== undefined ) {
            this.MapChipsInit(mapchips);
            this.GuiFrameInit(systemGraphics);
            this.ActorSpriteInit(actorGraphics);
        }
        // オブジェクトをステージに追加
        this.addStageInit();
    }

undefinedの条件も足しているので忘れないようにしてください。

まだ読み込むファイルが少ないのでいいのですが、undefined条件も含め増えてくるとどんどん記述が長くなってしまいます。

私はこの時点でまだスタイリッシュに書く方法がわからないのですが、
そのうちディレクトリの中を見て中にあるものをすべて読み込むという処理を作れたら公開しようと思います。

そして読み込んでいるactorGraphics関数を作成しましょう。

actorGraphics関数の作成

今回は時間の兼ね合いもあってドラクエ1風に蟹歩き状態で進めます。
即興でプレイヤードット絵を2つ描いたのでそれを使います。

    // プレイヤー初期化
    private ActorSpriteInit(textureData:PIXI.Spritesheet) {
        let textureActors:Array<PIXI.Texture> = [];
        textureActors.push(textureData.textures["actor01_c0.png"]);
        textureActors.push(textureData.textures["actor01_c1.png"]);
        this.PlayerSprite = new PIXI.extras.AnimatedSprite(textureActors);
        this.PlayerSprite.setTransform(SYSTEM.guiChipSize*2 + SYSTEM.chipSize,SYSTEM.guiChipSize*2 + SYSTEM.chipSize);
        this.PlayerSprite.animationSpeed = 0.05;
        this.PlayerSprite.play();
    }

PIXI.extras.AnimatedSpriteはSpriteと基本は同じですが、少しだけ扱いが変わってきます。

インスタンスを作成する時に引数に渡すものはテクスチャをまとめた配列になりますので
ローカル変数に渡されてきたテクスチャを持つオブジェクトから抜き出して、テクスチャの配列を作りましょう。

今回は2つしかないのでforを使わずに直接抜き取ってpushメソッドで配列にテクスチャを取り出して追加しています。

次に、x座標とy座標を0のままにしてしまってはGUIの外側にいる状態になってしまうので初期位置を補正してみましょう。

SYSTEMモジュールにGuiFrameサイズとチップサイズを記述しているので計算して初期位置を指定しましょう。

その際、xとyを別々に指定してもいいのですが位置やスケールを設定できるsetTransformを使って
1行でxとyの位置を指定しましょう。

スケールも設定できるのですが、省略することが可能で引数の1つ目と2つ目にするとxとyだけ設定できます。

animationSpeedとplayメソッドについて

キャラクターをアニメーションさせるにはこの2つのメソッドを設定することで
自動的にキャラクターアニメーションを設定してくれます。

今回は蟹歩きで常に足踏みをしている状態にしたいので、アニメーションスピードと実行だけで済んでいますが
横向きとかを実装する場合はもう少し設定画複雑になります。

今回は時間の関係上省略しますが、そのうち別途記事で説明しようと思います。

ついでにステージにも追加しておきましょう

    // 初回ステージ追加
    private addStageInit() {
        this.addStageArray(this.MapChips);
        this.addStageArray(this.GuiFrame);
        this.addStageArrayGraphic(this.GuiFrameBacks);
        this.App.stage.addChild(this.PlayerSprite);
    }

プレイヤーは配列ではなく変数なので普通にステージに追加しています。

一旦コンパイルをして正常にキャラクターが表示できているか見てみましょう。

pixi.js コンパイル結果

このように出ていれば成功です。

ちゃんとキャラクターもアニメーションしています。

2つの絵しかないので常に足踏みしている状態ですが静止しているよりはいいでしょう。

キー操作でプレイヤーを操作してみよう

冒頭でダウンロードしてもらったファイルに私がマップエディタを作る際についでに作ったキー操作のクラスファイルを使います。
まだまだ未完成なので、必要最低限の機能を用意しました。

キー操作クラスの作り方も説明しようと思いましたが、ファイルをまたぐと説明し辛くなるのと
これも時間の都合上省略させていただきます。解説は落ち着いてから後程。

それではキー操作クラスファイルを読み込んでみましょう。

import Controller_Base from './Controller/Controller_Base';

クラスの上部でpixi.js等を読み込んでいる部分で読み込みます。
そして最下部でクラスのインスタンスを生成しておきましょう。

const Controller:Controller_Base = new Controller_Base;

こうすると使用できるようになります。

キー操作でプレイヤーを操作してみよう

一度矩形をキーで操作したことがある人ならもう簡単ですね。

以下のプレイヤーコントローラー関数を作成しましょう。

    // プレイヤー操作関数
    private PlayerController(delta:number) {
        if(Controller_Base.keyAry.indexOf("ArrowLeft") >= 0) {
            this.PlayerSprite.x -= 1 + delta;
        }
        else if(Controller_Base.keyAry.indexOf("ArrowRight") >= 0) {
            this.PlayerSprite.x += 1 + delta;
        }        
        else if(Controller_Base.keyAry.indexOf("ArrowUp") >= 0) {
            this.PlayerSprite.y -= 1 + delta;
        }
        else if(Controller_Base.keyAry.indexOf("ArrowDown") >= 0) {
            this.PlayerSprite.y += 1 + delta;
        }
    }

まずは方向キー入力のみにします。

押されたキーに対して処理を割り振っているだけですね。

引数のdeltaはticker関数から渡されます。

私もまだ詳しくないのですがFPSが変化した時に補正してくれるものだそうです。
今は例に習って入れておきましょう。そのうち重たいゲームを作ったときに恩恵が来ると思います。

そしてこの関数は1フレーム単位で監視したいのでループ関数の中に組み込んでおきます。

    // メインループ
    private gameloop(delta:number) {
        this.PlayerController(delta);
    }

このままでは動かないので、ticker関数を設定しましょう。

    // セットアップ関数
    private setup() {
        this.App.ticker.add(delta => this.gameloop(delta));
    }

クラスのコンストラクタで読み込んでも良いですが、画像のロードが終わってからでいいので
ローダーに記述しているスプライトのファイル読み込み後に呼び出すようにしてみましょう。

    // PIXIローダー
    private loadingResrouces() {

        ~~~省略~~~

        // ロード終了後に実行する関数を指定する
        .load(() => {
            // スプライトセットアップ
            this.spriteSetup();
            //  ループ関数起動
            this.setup();
        })
    }

これでコンパイルして動かしてみましょう。

キー操作でキャラクターが動くようになったはずです。

pixi.js コンパイル結果

……どうでしょうか?

なんかゲームっぽくないですよね。

それもそうでまだ衝突判定も作っていませんから壁にもめり込めますし画面外にも行けてしまいます。

これはタイトルにも書いた通りドット移動と言って1ドットずつ動くので滑らかに動けます。

スクウェア時代のゲームで言えば聖剣伝説シリーズやクロノトリガーみたいな移動方法に使える手法です。

しかしドット単位で衝突判定を作らないといけないので制作難易度はアクションゲーム並みにあがります。

次回はグリッド移動に差し替えるのですが、ちょっとした計算式が必要になります。

ネタバレするとここまでで何回も使ってきた割った余りを使った処理になります。

FFやドラクエ等の移動って1歩歩いたら1マス進む感じですよね。

あれはグリッド移動と言って1回キーを押されたら指定の位置まで移動し続けるという処理を取っています。

グリッドはサイズが全部一緒なので、現在座標をグリッドサイズで割って余りが0の時にキャラクターの移動を停止すれば良いです。

移動している際はキー操作を無効にしておくとかすれば、移動が完了するまで余計な移動処理が入らなくなります。

いつも通り最後に今回のソースコードを載せておきます。

import * as PIXI from 'pixi.js'
import { SYSTEM } from '../../config/system';

import Controller_Base from './Controller/Controller_Base';

class PIXI_MainProcess {

    /**
     * ----------------------------------------------------
     * エイリアス作成
     * ----------------------------------------------------
     *  */ 
    // pixi.jsアプリケーション
    private PIXI_Application = PIXI.Application
    // ローダー
    private PIXI_loader = PIXI.loader
    // リソースローダー
    private PIXI_resources= PIXI.loader.resources

    /**
     * ----------------------------------------------------
     * クラス変数
     * ----------------------------------------------------
     *  */ 
    // pixiアプリケーション生成
    private App:PIXI.Application = this.pixiApplicationCreate(SYSTEM.width,SYSTEM.height);

    //--------------------------------------------

    // プレイヤー用変数
    private PlayerSprite:PIXI.extras.AnimatedSprite = new PIXI.extras.AnimatedSprite([PIXI.Texture.EMPTY]);

    // マップ用変数
    private MapChips:Array<PIXI.Sprite> = [];

    // Window背景変数
    private GuiFrameBacks:Array<PIXI.Graphics> = [];

    // GUI用変数
    private GuiFrame:Array<PIXI.Sprite> = [];

    // マップデータ
    private MapData:Array<number> = [
        2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,
        2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
    ]

    //--------------------------------------------

    // コンストラクタ
    constructor() {

        // 初期化プロセス
        this.initializePIXI();
    }

    // 初期化プロセス
    private initializePIXI() {
        // ピクセル倍率変更
        this.pixelScaleUP();

        // htmlのbodyにPIXIAPPを追加
        document.body.appendChild(this.App.view);

        // Sprite初期化
        this.loadingResrouces();

    }

    // PIXIアプリケーションオブジェクト作成
    private pixiApplicationCreate(screenWidth:number,screenHeight:number) {
        return new this.PIXI_Application({
            width:screenWidth,
            height:screenHeight,
            antialias: false,
            transparent: false,
            resolution: 2
            }
        );
    }

    // ピクセル倍率を2倍に変更
    private pixelScaleUP() {
        this.App.renderer.roundPixels = true;
        this.App.renderer.resize(SYSTEM.width, SYSTEM.height);
        this.App.stage.scale.set(1,1);
        this.App.stage.interactive = true;
        PIXI.settings.SCALE_MODE = PIXI.SCALE_MODES.NEAREST;
    }


    // // セットアップ関数
    private setup() {
        this.App.ticker.add(delta => this.gameloop(delta));
    }

    // メインループ
    private gameloop(delta:number) {
        this.PlayerController(delta);
    }

    // PIXIローダー
    private loadingResrouces() {
        // エイリアスで設定したローダーの呼び出し
        this.PIXI_loader
        // addメソッドの引数にロードしたい画像ファイルパスを渡す
        .add(__dirname + "/src/assets/images/games/mapchip/testmap.png")
        .add(__dirname + "/src/assets/images/games/system/system.png")
        .add(__dirname + "/src/assets/images/games/actor/actor01.png")
        .add(__dirname + "/src/json/spritesheets/mapchip/testmap.json")
        .add(__dirname + "/src/json/spritesheets/system/system.json")
        .add(__dirname + "/src/json/spritesheets/actor/actor01.json")
        // チェーンメソッドでプログレス機能を稼働
        .on("progress", (loader, resource)=>{
            console.log("progress: " + loader.progress + "%"); 
        })
        // ロード終了後に実行する関数を指定する
        .load(() => {
            // 
            this.spriteSetup();
            // //  ループ関数起動
            // this.setup();
        })
    }

    // テクスチャネームを返す
    private getTextureName(str:string, i:number) {
        return `${str}${i}.png`;
    }


    // セットアップ関数
    private spriteSetup() {
        // テクスチャを準備する
        let mapchips:PIXI.Spritesheet | undefined = this.PIXI_resources[__dirname + "/src/json/spritesheets/mapchip/testmap.json"].spritesheet;
        let systemGraphics:PIXI.Spritesheet | undefined = this.PIXI_resources[__dirname + "/src/json/spritesheets/system/system.json"].spritesheet;
        let actorGraphics:PIXI.Spritesheet | undefined = this.PIXI_resources[__dirname + "/src/json/spritesheets/actor/actor01.json"].spritesheet;
        if(mapchips !== undefined && systemGraphics !== undefined && actorGraphics !== undefined ) {
            this.MapChipsInit(mapchips);
            this.GuiFrameInit(systemGraphics);
            this.ActorSpriteInit(actorGraphics);
        }
        // オブジェクトをステージに追加
        this.addStageInit();
    }

    // プレイヤー初期化
    private ActorSpriteInit(textureData:PIXI.Spritesheet) {
        let textureActors:Array<PIXI.Texture> = [];
        textureActors.push(textureData.textures["actor01_c0.png"]);
        textureActors.push(textureData.textures["actor01_c1.png"]);
        this.PlayerSprite = new PIXI.extras.AnimatedSprite(textureActors);
        this.PlayerSprite.setTransform(SYSTEM.guiChipSize*2 + SYSTEM.chipSize,SYSTEM.guiChipSize*2 + SYSTEM.chipSize);
        this.PlayerSprite.animationSpeed = 0.05;
        this.PlayerSprite.play();
    }

    // プレイヤー操作関数
    private PlayerController(delta:number) {
        if(Controller_Base.keyAry.indexOf("ArrowLeft") >= 0) {
            this.PlayerSprite.x -= 1 + delta;
        }
        else if(Controller_Base.keyAry.indexOf("ArrowRight") >= 0) {
            this.PlayerSprite.x += 1 + delta;
        }        
        else if(Controller_Base.keyAry.indexOf("ArrowUp") >= 0) {
            this.PlayerSprite.y -= 1 + delta;
        }
        else if(Controller_Base.keyAry.indexOf("ArrowDown") >= 0) {
            this.PlayerSprite.y += 1 + delta;
        }
    }

    // マップチップ初期化
    private MapChipsInit(textureData:PIXI.Spritesheet) {
        // y座標折り返しカウント
        let yCount:number = 0;
        // 画面サイズ分のマップチップ配列生成
        for (let i=0; i<this.MapData.length; i++ ) {
            // 長いのでテクスチャ名のエイリアスを作っておく
            let textureName = this.getTextureName(textureData.data.meta.name,this.MapData[i]);
            // i番目の配列にnewする
            this.MapChips[i] = new PIXI.Sprite(textureData.textures[textureName]);
            // i番目のx座標設定
            this.MapChips[i].x = ((i%SYSTEM.gridX) * SYSTEM.chipSize) + SYSTEM.fixMapPosition;
            // X座標が端っこまで行ったら改行する
            if(i !== 0 && i%SYSTEM.gridX === 0) {
              // y座標折り返しカウントをインクリメント
              yCount++;
            } 
            // i番目のy座標設定
            this.MapChips[i].y = yCount * SYSTEM.chipSize + SYSTEM.fixMapPosition;
            // i番目の横幅設定
            this.MapChips[i].width = SYSTEM.chipSize;
            // i番目の縦幅設定
            this.MapChips[i].height = SYSTEM.chipSize;
        }
    }

    // GUIフレーム用意関数
    private GuiFrameInit(textureData:PIXI.Spritesheet) {

        // iを8で割った余りによって生成するものを変化させる
        for (let i=0; i<SYSTEM.guiParams.length; i++) {
            if(i%8 < 4) {
                switch(i%8) {
                    case 0:
                        // フレーム左上
                        this.GuiFrame[i] = new PIXI.Sprite(textureData.textures["windowLeftTop.png"]);
                        break;
                    case 1:
                        // フレーム左下
                        this.GuiFrame[i] = new PIXI.Sprite(textureData.textures["windowLeftBottom.png"]);
                        break;
                    case 2:
                        // フレーム右上
                        this.GuiFrame[i] = new PIXI.Sprite(textureData.textures["windowRightTop.png"]);
                        break;
                    case 3:
                        // フレーム右下
                        this.GuiFrame[i] = new PIXI.Sprite(textureData.textures["windowRightBottom.png"]);
                        break;
                }
            } else {
                switch(i%8) {
                    case 4:
                        // フレーム上部繰り返し用
                        this.GuiFrame[i] = new PIXI.extras.TilingSprite(textureData.textures["windowRepeatFrameTop.png"]);
                        break;
                    case 5:
                        // フレーム下部繰り返し用
                        this.GuiFrame[i] = new PIXI.extras.TilingSprite(textureData.textures["windowRepeatFrameBottom.png"]);
                        break;
                    case 6:
                        // フレーム左部繰り返し用
                        this.GuiFrame[i] = new PIXI.extras.TilingSprite(textureData.textures["windowRepeatFrameTop.png"]);
                        break;
                    case 7:
                        // フレーム右部繰り返し用
                        this.GuiFrame[i] = new PIXI.extras.TilingSprite(textureData.textures["windowRepeatFrameBottom.png"]);
                        break;
                }                
            }
        }

        // ウィンドウ背景スプライト生成
        for (let i=0; i<SYSTEM.windowBack.length; i++) {
            this.GuiFrameBacks[i] = new PIXI.Graphics();
        }

        // ウィンドウ背景設定
        this.GuiFrameBacks.map((item,key) => {
            item.x = SYSTEM.windowBack[key].x;
            item.y = SYSTEM.windowBack[key].y;
            item.width = SYSTEM.windowBack[key].width;
            item.height = SYSTEM.windowBack[key].height;
            item.alpha = SYSTEM.windowBack[key].alpha;
            item.beginFill(SYSTEM.windowBack[key].color);
            item.drawRect(0,0,SYSTEM.windowBack[key].width,SYSTEM.windowBack[key].height);
            item.endFill();
        });

        // 全てのGUIチップサイズを一旦設定
        this.GuiFrame.map( (item,key) => {
            item.x = SYSTEM.guiParams[key].x;
            item.y = SYSTEM.guiParams[key].y;
            item.width = SYSTEM.guiParams[key].width;
            item.height = SYSTEM.guiParams[key].height;
            item.rotation = SYSTEM.guiParams[key].rotation;
            item.anchor.set( SYSTEM.guiParams[key].anchor,SYSTEM.guiParams[key].anchor);
        });
    }

    // 初回ステージ追加
    private addStageInit() {
        // マップチップ配列をステージに追加
        this.addStageArray(this.MapChips);
        this.addStageArray(this.GuiFrame);
        this.addStageArrayGraphic(this.GuiFrameBacks);
        this.App.stage.addChild(this.PlayerSprite);
        // // GUIフレーム配列をステージに追加
        // this.addStageArray(this.GuiFrame);
    }

    // 配列ステージ追加メソッド
    private addStageArray(ary:Array<PIXI.Sprite>) {
        ary.map( (item) => {
            this.App.stage.addChild(item);
        });
    }
    // 配列ステージ追加メソッド
    private addStageArrayGraphic(ary:Array<PIXI.Graphics>) {
        ary.map( (item) => {
            this.App.stage.addChild(item);
        });
    }

}

export default PIXI_MainProcess;

const Controller:Controller_Base = new Controller_Base; 
const GameFrame:PIXI_MainProcess = new PIXI_MainProcess;