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以上がなかったので下記手順を参考に更新。
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の中身を以下のように変更
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)をここに置いておきます
最終的な構成は以下の感じ
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.ts
とApp.tsx
を更新します。
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
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
/// <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に下記を記載
{
"compilerOptions": {
//...
"typeRoots": [
"@types",
"node_modules/@types",
"node_module/phaser/types"
],
"types": [
"phaser"
]
//...
}
}
そしてApp.tsx
をSpinePluginを使うように修正
//...
+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, ゲームオブジェクトの追加をする
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.image
とthis.add.sprite
で画像が表示されるので、assets/sd_player.png
を表示できるかなど試行錯誤してみましょう
せっかくなのでプラットフォーマー的にキャラクターを動かそう。Arcadeで重力、衝突判定など作ります。
画面タップ(クリック中)で移動、タップ終了時にジャンプする簡易なアニメーション遷移。
Arcadeを追加
const config: Phaser.Types.Core.GameConfig = {
// ...
physics: {
default: 'arcade',
arcade: {
gravity: { y: 600 },
debug: true
}
},
// ...
create関数を編集
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オブジェクトのバウンドボックスと描写部分がリンクしていないので、反転したら衝突部分がずれる
参考にした
ついでにSpine全体を囲むバウンドボックスだと当たり判定でかすぎるので、微妙な値に修正。 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
+ }
// ...
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で解消されているはずなので期待……
ぜんぜん直ってなかった。簡単に壁抜けする程度にはバグっている。もうちょっときれいになるように調査続行
この記事をまんまるコピペで解決
このスクラップを記事にするならこのコピペの内容も理解しないとな・・・