Closed13

React+Phaser+Typescript+Spine構築編

ピン留めされたアイテム
レモンレモン

あとで纏めて(多分)記事にします

レモンレモン

いつもの便利なやつ

npx create-react-app --template typescript phaser-spine-game-sample

終わったらとりあえず起動

cd phaser-spine-game-sample
yarn start

いつもの画面
タイトル画面

レモンレモン

Phaserを入れる

yarn add phaser
レモンレモン

なんかエラー出たね

[2/4] Fetching packages...
error eslint@8.7.0: The engine "node" is incompatible with this module. Expected version "^12.22.0 || ^14.17.0 || >=16.0.0". Got "15.11.0"
error Found incompatible module.
レモンレモン
node -v
> v15.11.0

Node.jsのバージョンが駄目みたい。
create-react-appで起こる現象なので、とりあえずNodeのバージョンをアップデートする。(Next.js使ってるときはこんなエラー出ないんだけどな~)
ちなみにanyenvからのnodenvを使用しているので更新はらくちん。
ただnodenvのリストにv16以上がなかったので下記手順を参考に更新。
https://qiita.com/sawadashota/items/825002d84088c0129c4b

nodenv install 17.4.0
nodenv local 17.4.0
node -v
> 17.4.0
yarn add phaser
...
Done in 97.74s.

phaserのインストールに成功
こんなところで詰まるとは…

レモンレモン

App.tsxの中身を以下のように変更

App.tsx
function App() {
  return (
    <div id="game" className="App">
      Hello
    </div>
  );
}
export default App;

するとさっきのページ(http://localhost:3000/)は真っ白になる。

ゲーム用のディレクトリも作成しましょう。

mkdir -p public/assets
touch src/game/main.ts

spineでエクスポートしたpng、atlas、jsonもsrc/public/assetsに追加

今回作成したSpineの素材ファイル(png, atlas, json)をここに置いておきます
https://github.com/citrono-lemon/phaser-spine-typescript-sample/tree/main/public/assets

最終的な構成は以下の感じ

tree src public
public
├── assets
│   ├── sd_player.atlas
│   ├── sd_player.json
│   └── sd_player.png
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
├── robots.txt
└── sd_player.png
src
├── App.css
├── App.test.tsx
├── App.tsx
├── game
│   └── main.ts
├── index.css
├── index.tsx
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
└── setupTests.ts

この辺はお好みでどうぞ。
ただし、アセットが読み込み可能な下層にあること!

レモンレモン

game/main.tsApp.tsxを更新します。
game/main.tsは、現時点ではほぼ空っぽです。

game/main.ts
import 'phaser'

/**
 * メインシーン
 * 一応説明しておくと、
 * init⇒preload⇒create⇒update⇒update⇒...
 * のようなライフサイクルで動作する
 */
class MainScene extends Phaser.Scene {
  constructor() {
    super({
      key: 'Main',
    })
  }

  /**
   * 初期処理
   */
  init(): void {
    console.log("init")
  }

  /**
   * アセットデータ読込などを行う処理
   */
  preload(): void {
    console.log("preload")
  }

  /**
   * ゲーム画面の作成処理やイベントアクションを記述する処理
   */
  create(): void {
    console.log("create")
    this.add.text(10, 10, "Hello, phaser")
  }

  /**
   * メインループ
   */
  update(): void {
  }
}

export default MainScene
App.tsx
import { CSSProperties, useEffect } from 'react';
import 'phaser'
import MainScene from './game/main';

// Phaserの設定
const config: Phaser.Types.Core.GameConfig = {
  width: 1280,
  height: 720,
  type: Phaser.AUTO,
  pixelArt: false,
  backgroundColor: 0xcdcdcd,

  scale: {
    mode: Phaser.Scale.FIT,
    autoCenter: Phaser.Scale.CENTER_VERTICALLY,
    parent: 'game',
    fullscreenTarget: 'game'
  },

  // ここで読み込むシーンを取得する
  // 今回は軽いテストなので、MainSceneのみ
  scene: [MainScene],
};

/**
 * PhaserのGameを生成するためのクラス
 */
class Game extends Phaser.Game {
  constructor(config: Phaser.Types.Core.GameConfig) {
    super(config);
  }
}

/**
 * ゲームを描写するDivコンポーネント
 */
const App: React.FC<{ className?: string }> = ({ className }) => {
  // お手軽にCSSの設定(フルスクリーンで、Canvasを中央寄せにする)
  const style: CSSProperties = {
    width: "100vw",
    height: "100vh",
    textAlign: "center"
  }

  // 画面の発描写時に実行する
  // 画面の終了時にはGameをDestroyする
  useEffect(() => {
    const g = new Game(config)
    return () => {
      g?.destroy(true)
    }
  }, []);

  // canvasをAppendするdivコンポーネント
  return (
    <div id="game" className={className} style={style}>
    </div >
  )
}

export default App;

実行すると、フルスクリーンで文字が表示されます。
実行結果

ゲームを作る準備が出来ました。

レモンレモン

ここからがSpineを使う設定です。

まずはSpinePluginのリファレンスパスを記述したファイルを作成する

mkdir -p @types/phaser
touch @types/phaser/index.d.ts
@types/phaser/index.d.ts
/// <reference path="../../node_modules/phaser/types/SpineFile.d.ts" />
/// <reference path="../../node_modules/phaser/types/SpineGameObject.d.ts" />
/// <reference path="../../node_modules/phaser/types/SpinePlugin.d.ts" />

次にtsconfig.jsonに下記を記載

tsconfig.json
{
  "compilerOptions": {
  //...
    "typeRoots": [
      "@types",
      "node_modules/@types",
      "node_module/phaser/types"
    ],
    "types": [
      "phaser"
    ]
  //...
  }
}

そしてApp.tsxをSpinePluginを使うように修正

App.tsx
//...
+import 'phaser/plugins/spine/dist/SpinePlugin'
//...

// Phaserの設定
const config: Phaser.Types.Core.GameConfig = {
//...
+ plugins: {
+   scene: [
+     { key: 'SpinePlugin', plugin: SpinePlugin, mapping: 'spine' }
+   ]
+ }
}

//...

  useEffect(() => {
    const g = new Game(config)
    return () => {
      g?.destroy(true)
+     g?.plugins.removeScenePlugin('SpinePlugin') 
    }
  }, []);

// ...

最後にmain.tsから、Spineをload, ゲームオブジェクトの追加をする

game/main.ts
  preload(): void {
    console.log("preload")
+   this.load.setPath("assets")
+   this.load.spine("player", "sd_player.json", ["sd_player.atlas"], true)
  }

  create(): void {
    console.log("create")
    this.add.text(10, 10, "Hello, phaser")
+   this.add.spine(100, 200, "player", "Idle", true).setScale(1, 1)
  }

上手く行かない場合は、とりあえずthis.load.imagethis.add.spriteで画像が表示されるので、assets/sd_player.pngを表示できるかなど試行錯誤してみましょう

表示結果

レモンレモン

せっかくなのでプラットフォーマー的にキャラクターを動かそう。Arcadeで重力、衝突判定など作ります。
画面タップ(クリック中)で移動、タップ終了時にジャンプする簡易なアニメーション遷移。

Arcadeを追加

App.tsx
const config: Phaser.Types.Core.GameConfig = {
  // ...
  physics: {
    default: 'arcade',
    arcade: {
      gravity: { y: 600 },
      debug: true
    }
  },
  // ...

create関数を編集

game/main.ts
  create(): void {
    console.log("create")

    // 壁
    const offset = 40
    const [W, H] = [this.cameras.main.width, this.cameras.main.height]
    const platformColor = 0x456789
    const platform = this.physics.add.staticGroup()
    platform.addMultiple([
      this.add.rectangle(0, H - offset, W, H, platformColor).setOrigin(0, 0),
      this.add.rectangle(0, 0, W, offset, platformColor).setOrigin(0, 0),
      this.add.rectangle(0, 0, offset, H, platformColor).setOrigin(0, 0),
      this.add.rectangle(W - offset, 0, offset, H, platformColor).setOrigin(0, 0),
    ])

    // プレイヤー
    const playerPos = { x: 240, y: 500 }
    const spineObject = this.add.spine(playerPos.x, playerPos.y, "player", "Idle2", true)
      .setScale(1, 1)
      //.setSize(40, 100, playerPos.x - 20, H - playerPos.y - 100 - 5)
      .setMix("Idle2", "Jump", 0.3)
      .setMix("Idle2", "Walk", 0.3)
      .setMix("Walk", "Jump", 0.3)
      .setMix("Jump", "Idle2", 0.3)
    const player = this.physics.add.group(spineObject)

    // プレイヤーと床の衝突を追加
    this.physics.add.collider(platform, player, (plfm, plyr) => {
      if (spineObject.state.getCurrent(0).animation.name == "Jump") {
        spineObject.state.setAnimation(0, "Idle2", true)
        player.setVelocityX(0)
      }
    }, undefined, this)

    // 左右移動ボタンとジャンプの追加

    this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
      if (p.position.x > spineObject.x) {
        spineObject.setScale(1, 1)
        player.setVelocityX(300)
      }
      else {
        spineObject.setScale(-1, 1)

        player.setVelocityX(-300)
      }
      spineObject.state.setAnimation(0, "Walk", true)
    }).on("pointerup", () => {
      player.setVelocityY(-500)
      spineObject.state.setAnimation(0, "Jump", true)
    })
  }
レモンレモン

ここで左に移動するときはキャラ画像を反転(xScale=-1)しようとしたが問題発生。
Spineオブジェクトのバウンドボックスと描写部分がリンクしていないので、反転したら衝突部分がずれる

レモンレモン

参考にした
https://phaser.discourse.group/t/phaser-3-spine-examples-change-skins-animations-and-attachments/1042/9
ついでにSpine全体を囲むバウンドボックスだと当たり判定でかすぎるので、微妙な値に修正。

game.main.ts
  create(): void {
    // ...
    // プレイヤー
    const playerPos = { x: 240, y: 500 }
    const spineObject = this.add.spine(playerPos.x, playerPos.y, "player", "Idle2", true)
      .setScale(1, 1)
+     .setSize(40, 100, playerPos.x - 20, H - playerPos.y - 100 - 5)
      .setMix("Idle2", "Jump", 0.3)
      .setMix("Idle2", "Walk", 0.3)
      .setMix("Walk", "Jump", 0.3)
      .setMix("Jump", "Idle2", 0.3)
+   spineObject.setFlipX = function (flip) {
+     this.body = this.body as Phaser.Physics.Arcade.Body
+     if (flip) {
+       this.body.setOffset(this.width - 20, 20)
+       this.setScale(-1, 1)
+     }
+     else {
+       this.body.setOffset(20, 20)
+       this.setScale(1, 1)
+     }
+     return this
+   }
    // ...
game.main.ts
    this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
      if (p.position.x > spineObject.x) {
-       spineObject.setScale(1, 1)
+       spineObject.setFlipX(false)
        player.setVelocityX(300)
      }
      else {
-       spineObject.setScale(-1, 1)
+       spineObject.setFlipX(true)
        player.setVelocityX(-300)
      }
      spineObject.state.setAnimation(0, "Walk", true)
    }).on("pointerup", () => {
      player.setVelocityY(-500)
      spineObject.state.setAnimation(0, "Jump", true)
    })

スケール変更したらまたバグるだろうしあまり根本的な解決になってない気もするけど、まぁPhaser4で上手くなっていることを期待……
あとPhaser3とtypescriptの相性があまり良くない。これもPhaser4で解消されているはずなので期待……

レモンレモン

ぜんぜん直ってなかった。簡単に壁抜けする程度にはバグっている。もうちょっときれいになるように調査続行

このスクラップは2022/05/02にクローズされました