20
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

reactでポケモン風RPGゲームを作ってみよう!戦闘画面編

Last updated at Posted at 2022-09-05

スクリーンショット 2022-09-05 14.53.50.jpg

楽しみながらプログラムを書くのが上達の近道

プログラミングを学びたての頃は、コードを書くのは大変で、難しいことばかり。わからないことだらけで辛い…。と感じる方は多いと思います。
私も昔はそうでした。でも、ある時JavaScriptを使ってオセロやぷよぷよのようなゲームを作ることに挑戦したことで、プログラミングの楽しさがわかり、どんどん作りたいという気持ちが湧いてくるようになりました。

プログラミングを学びたてだったり、新しい言語を勉強するときには、体型的にプログラムそのものを勉強するというよりも、何か自分が作ってみたいものや、ワクワクするものを作り初めてしまうことが上達の近道だったりすると思います。

ゲームを作りは最高の学習方法!

そこで、今回はゲームを作ってReactを学んで行こうと思います。ゲームを作るのは本当に楽しいです。
ゲームといえばやっぱりポケモン!ということで、Reactを使ってポケモン風のRPGゲームを作ってみます。

Reactは常にキャラクターが動いているようなゲームの開発には向いていませんが、ポケモンのようなコマンド式のゲームにはぴったりだと思います。この記事がReactを楽しみながら勉強したい方の参考になれば幸いです。

完成版はこちら!

スクリーンショット 2022-09-05 12.14.45.jpg

今回作るポケモンライクなRPGの完成版はこちらから無料で遊べます。
遊んでみてね(結構難しいです)
https://chimerical-dasik-a1eaca.netlify.app/

Reactの環境構築

Nodeやnpmなどのインストールが終わっている前提で進めます。

まずは、Reactのプロジェクトを作ります。

npx create-react-app react-monster
cd react-monster
npm start

これで無事、Reactが動くことが確認できました。
スクリーンショット 2022-08-31 12.27.15.jpg

戦闘UIを構築する

まずは、RPGゲームで一番大事な戦闘システムから開発していきます。

最初にUIを用意します。いくつかコンポーネントを作り、スタイルを当てていきます。

スクリーンショット 2022-09-05 12.14.45.jpg

せっかくなのでオリジナルモンスターを作ることにしました。モンスターの画像は低予算の都合上により絵文字となっています。

相手のリアモン(リアクトモンスター)は、全エンジニアが恐怖する「SQLインジェクション」です。怖いですね〜。味方のリアモンは「みならいプログラマー」です。

「みならいプログラマー」を操作して、「SQLインジェクション」を退治できることが今回のゴールです。

/src/scene/battle.js'

戦闘シーンを記述する/src/scene/battle.js'を作成します。

このファイルに基本的な戦闘に関するロジックを書いていきます。

また、先ほど作ったUIを3つのコンポーネントに分割して、並べています。

  • 敵(Oponent)
  • 味方(Player)
  • メッセージ(Message)

戦闘シーンで使用するコンポーネントは/components/battleディレクトリにまとめます。

import React, { useState } from 'react';
import Oponent from "../components/battle/Oponent";
import Player from "../components/battle/Player";
import Message from "../components/battle/Message";

const BattleScene = () => {
    return (
        <div style={battleSceneStyle.battleScene}>
            <Oponent />
            <Player />
            <Message />
        </div>
    );
}

const battleSceneStyle = {
    battleScene: {
        width: '100%',
        height: '100%',
        padding: '10px',
        boxSizing: 'border-box',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'space-between',
    },
}

export default BattleScene;

Reactでは、CSSをjsで書くことができます。
styleオブジェクトを作成して、CSSのプロパティを追加していきます。通常のCSSと異なり、プロパティ名がキャメルケース(boxSizingなど)になることに注意してください。

/components/battle/Oponent.js

スクリーンショット 2022-08-31 16.31.53.jpg

敵の情報が表示されるコンポーネントがこちらです。

まだstateや変数は使わず、基本的な見た目だけをHTML(JSX)とCSSを使って書いていきます。

/components/battle/Oponent.js

const Oponent = () => {
    return (
        <div style={oponentStyle.oponentContainer}>
            <OponentBattleIndicator />
            <p style={oponentStyle.oponentImage}>😈</p>
        </div>
    );
}

const OponentBattleIndicator = () => {
    return (
        <div style={oponentStyle.battleIndicaterContainer}>
            <span style={oponentStyle.battleIndicaterName}>SQLインジェクション:L5</span>
            <div>
                <span>HP</span>
                <span style={oponentStyle.hpString}>100/100</span>
            </div>
        </div>
    );
}

const oponentStyle = {
    battleIndicaterContainer: {
        witdh: '100%',
        height: '50px',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'left',
        justifyContent: 'center',
        paddingBottom: '12px',
        paddingLeft: '24px',
        borderBottom: '2px solid #000',
        borderLeft: '2px solid #000'
    },
    battleIndicaterName: {
        textAlign: 'right',
    },
    oponentContainer: {
        display: 'flex',
        justifyContent: 'end',
        justifyContent: 'space-between',
        padding: '24px',
    },
    oponentImage: {
        fontSize: '60px',
    },
    hpString: {
        fontWeight: 'bold',
        marginLeft: '4px',
        fontSize: '16px',
    }
}

export default Oponent;

/components/battle/Player.js

スクリーンショット 2022-08-31 16.32.32.jpg

味方のモンスターの情報が表示されるコンポーネントです。Oponentコンポーネントと似ている部分がいくつかありますね。後から共通化できるコンポーネントは1つのファイルにまとめます。その方がメンテナンスやデバッグが楽になります。

ただ、最初はとにかく難しいことを考えず、動くものを作ることがモチベーションを維持するのに大事だと個人的には思います。

/components/battle/Player.js

const Player = () => {
    return (
        <div style={playerStyle.playerContainer}>
            <span style={playerStyle.playerImage}>🧑‍💻</span>
            <PlayerBattleIndicator />
        </div>
    );
}

const PlayerBattleIndicator = () => {
    return (
        <div style={playerStyle.battleIndicaterContainer}>
            <span style={playerStyle.battleIndicaterName}>みならいプログラマー:L5</span>
            <div>
                <span>HP</span>
                <span style={playerStyle.hpString}>100/100</span>
            </div>
        </div>
    );
}

const playerStyle = {
    battleIndicaterContainer: {
        witdh: '100%',
        height: '50px',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'left',
        justifyContent: 'center',
        paddingBottom: '12px',
        paddingRight: '24px',
        borderBottom: '2px solid #000',
        borderRight: '2px solid #000'
    },
    battleIndicaterName: {
        textAlign: 'left',
    },
    playerContainer: {
        display: 'flex',
        justifyContent: 'start',
        justifyContent: 'space-between',
        alignItems: 'center',
        padding: '12px',
    },
    playerImage: {
        fontSize: '100px',
    },
    hpString: {
        fontWeight: 'bold',
        marginLeft: '4px',
        fontSize: '18px',
    }
}

export default Player;

/components/battle/Message.js

スクリーンショット 2022-08-31 16.32.49.jpg

戦闘中のメッセージやコマンドが表示されるコンポーネントです。ユーザーの操作を受け取る重要なコンポーネントでもあります。
ステータスを定義して、ステータスを変更することでコマンドの表示・非表示を切り替えるようにしています。

/components/battle/Message.js

const Message = () => {
    let status = 0;
    return (
        <div style={messageStyle.messageContainer}>
            {status === 0 &&(
                <div style={{...messageStyle.normalMessageContainer, ...messageStyle.border}}>
                    <p>あ!やせいの<br />SQLインジェクションがとびだしてきた!</p>
                </div>
            )}
            

            {status === 1 &&(
                <>
                <div style={{...messageStyle.normalMessageContainer, ...messageStyle.border}}>
                    <p>どうする?</p>
                </div>
                <div style={{...messageStyle.mainCommandContainer, ...messageStyle.border}}>
                    <span style={messageStyle.mainCommandText}>たたかう</span>
                    <span style={messageStyle.mainCommandText}>どうぐ</span>
                    <span style={messageStyle.mainCommandText}>リアモン</span>
                    <span style={messageStyle.mainCommandText}>にげる</span>
                </div>
            </>
            )}

            {status === 2 &&(
                <>
                <div style={{...messageStyle.normalMessageContainer, ...messageStyle.border}}>
                    <p></p>
                </div>
                <div style={{...messageStyle.skillComandContainer, ...messageStyle.border}}>
                     <span style={messageStyle.skillCommandText}>かいはつ</span>
                    <span style={messageStyle.skillCommandText}>ちょうさ</span>
                    <span style={messageStyle.skillCommandText}>べんきょう</span>
                    <span style={messageStyle.skillCommandText}>もどる↩︎</span>
                </div>
                <div style={{...messageStyle.skillDetailContainer, ...messageStyle.border}}>
                    <span style={messageStyle.skillRemainingPointText}>20/20</span>
                    <span>タイプ / ノーマル</span>
                    <span style={messageStyle.skillSelectButton}>くりだす</span>
                </div>
            </>
            )}
        </div>
    );
}

const messageStyle = {
    border: {
        border: '2px solid black',
        borderRadius: 4,
        backgroundColor: 'white',
    },
    messageContainer:{
        position: 'relative',
    },
    normalMessageContainer: {
        witdh: '100%',
        height: 80,
        padding: 12,
    },
    mainCommandContainer: {
        position: 'absolute',
        bottom: 0,
        right: 0,
        width: 200,
        height: 80,
        padding: 12,
        display: 'flex',
        flexWrap: 'wrap',
    },
    mainCommandText: {
        width: '50%',
    },
    skillCommandText: {
        width: '100%',
    },
    skillComandContainer:{
        position: 'absolute',
        bottom: 0,
        display: 'flex',
        flexWrap: 'wrap',
        flexDirection: 'column',
        padding: 12,
        gap:4,
        width: '50%',
    },
    skillDetailContainer:{
        width: '50%',
        position: 'absolute',
        padding: 12,
        gap:4,
        bottom: 0,
        right: 0,
        display: 'flex',
        flexWrap: 'wrap',
        flexDirection: 'column',
        alignItems: 'flex-end',
    },
    skillRemainingPointText: {
        fontWeight: 'bold',
        fontSize: 18,
    },
    skillSelectButton: {
        textDecoration: 'underline',
    }
}

export default Message;

ざっくりとUIをある程度作れたところで、これらを操作して動かせるようにしていきます。

見た目を作ると、よりワクワクしてきますね。

戦闘システムを作る

モンスターとの戦闘は以下の流れで行われます。

  1. リアモンとの遭遇(初回のみ)
  2. コマンドを選択
  3. モンスター同士の攻撃フェーズ
  4. (戦闘が終了するまで、2~4をくりかえす)
  5. 戦闘終了(最後のみ)

今回はこれらの流れをステータスとして管理して、戦闘を進めていくことにします。

コマンドを選択して戦闘を進められるようにする

まずは、コマンド操作の仕組みを作ります。

戦闘ステータスの定数を作成

新しく/src/constants/battle-constants.jsを作成します。

このファイルの中に、戦闘ステータスのIDを定数で書いていきます。

export const STATUS = {
    BATTLE_START: 0,
    SELECT_MAIN_COMMAND: 1,
    SELECT_SKILL_COMMAND: 2,
    ATTACK_PHASE: 3,
    BATTLE_END: 4,
}

このステータスIDをもとに、条件分岐しながら戦闘を進めるロジックを書いていきます。

stateを定義

/src/scene/battle.js'

statusとmessageTextステートを定義します。
先ほど作成したSTATUS定数をインポートして使います。

import { STATUS } from "../constants/battle-constants";

const BattleScene = () => {
	const [status, setStatus] = useState(STATUS.BATTLE_START);
	const [messageText, setMessageText] = useState(`あ! やせいの\nSQLインジェクションがあらわれた!`);
}

stateとは変数に似たようなもので、あらゆる値を保持できます。
stateは通常の変数と異なり、値が更新されると同時に画面の描画も更新されます。
そのため、画面の描画に関する値は基本的にstateを使って管理します。

stateを宣言する際には、以下のように描きます。

const [ステート名, ステート更新用関数名] = useState(デフォルト値);

useStateの引数には、デフォルトの値が設定できます。

とりあえず戦闘開始時の状態をデフォルトの値にセットします。

画面をクリックした時の動作を定義

定義したstatusの値で条件分岐して、画面上をクリックした場合の動作を切り替えます。

statusを更新するには、先ほど定義したsetStatus(変更後の値)を使います。

/src/scene/battle.js'

    // 画面上をクリックしたときの処理
    const onClickHandler = () => {
        switch (status) {
            case STATUS.BATTLE_START:
                goToMainCommand();
                break;

            default:
                break;
        }
    }

		// メインコマンド選択に戻る
    const goToMainCommand = () => {
        setStatus(STATUS.SELECT_MAIN_COMMAND);
        setMessageText("どうする?");
    }

戦闘開始時に画面をクリックすると、statusをSELECT_MAIN_COMMANDに変更し、messageTextを"どうする?"に変更されるようにします。

statusの値によって表示されるメニューを定義

MessageコンポーネントにstatusとmessageTextを渡します。

statusの値によってコマンドの表示・非表示を切り替えます。messageTextステートには画面下部に表示させたいテキストが格納されています。

/src/scene/battle.js'

<Message status={status} messageText={messageText} />

statusがSELECT_MAIN_COMMANDの場合のみ、MainCommandModalコンポーネントを表示させるようにします。

Message.js

// メッセージコンポーネント
const Message = (props) => {
    const { status, messageText } = props;

    return (
        <div style={style.messageContainer}>
            <MainMessage>{messageText}</MainMessage>
            {status === STATUS.SELECT_MAIN_COMMAND && <MainCommandModal {...props} />}
        </div>
    );
}

それでは、動かしてみましょう。

スクリーンショット 2022-09-05 12.14.45.jpg

戦闘開始ステータス時に、画面をクリックすると、、

スクリーンショット 2022-09-05 12.14.50.jpg

メッセージが進み、メインの対戦コマンドを選べるようになりました。

このようにして、画面のクリックをフックにステータスの値を更新する処理をどんどん作っていきます。

戦闘フェーズの処理を作成

コマンド選択の仕組みが完成したら、たたかうコマンドでわざを選んだ後に行われる、戦闘フェーズの処理を作ります。

スクリーンショット 2022-09-05 12.25.43.jpg

わざ選択後、戦闘フェーズを実行

BattleSceneコンポーネントにplayer、oponentのstateを作成します。

2つのstateをMessage、Oponent、Player等のコンポーネントに渡すことで、表示名やHPが描画されるようにします。

また、onSelectSkill()関数にメインの戦闘処理を書きます。この関数をMessageコンポーネントに渡し、わざを決定した場合に呼び出されるようにしています。


const BattleScene = () => { 

    const [player, setPlayer] = useState(DEFAULT_PLAYER);
    const [oponent, setOponent] = useState(DEFAULT_OPONENT);
    const [selectedSkillIndex, setSelectedSkillIndex] = useState(null);
    
    // 〜中略〜

    // わざを確定したときの処理
    const onSelectSkill = () => {

        // stateの更新はラグがあるため、変数に一旦格納
        const tempPlayer = { ...player };
        const tempOponent = { ...oponent };

        // 選択したわざ
        const selectedSkill = tempPlayer.skills[selectedSkillIndex];
        setSelectedSkillIndex(null);

        // わざのPPを減らす
        tempPlayer.skills[selectedSkillIndex].pp --;
        setPlayer(tempPlayer);

        // 時間経過で戦闘を自動送りするため、非同期処理を実行
        Promise.resolve()
            // プレイヤーが攻撃を宣言!
            .then(() => startPlayersAttack())
            .then(() => wait(MESSAGE_SPEED))
            // プレイヤーの攻撃フェーズ
            .then(() => playersAttack())
            .then(() => wait(MESSAGE_SPEED))
            // 敵が攻撃を宣言!
            .then(() => startOponentsAttack())
            .then(() => wait(MESSAGE_SPEED))
            // 敵の攻撃フェーズ
            .then(() => oponentsAttack())
            .then(() => wait(MESSAGE_SPEED))
            // プレイヤーのコマンド選択に戻る
            .then(() => goToMainCommand())
            // 例外発生時 
            .catch(err => handleError(err));
    }
}

戦闘フェーズでは時間経過でメッセージが自動で切り替わって欲しいです。

そのため、各メッセージが数秒置きに更新されるようにsetTimeoutとPromiseを駆使して非同期処理を作ります。

wait関数の中身はこちら

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

ちなみに、各定数の中身はこんな感じです。

味方、敵それぞれにHPや攻撃力を指定しています。

const MESSAGE_SPEED = 1500;

const DEFAULT_PLAYERS_SKILL = [
    {
        name: 'かいはつ',
        attack: 10,
        maxPoint: 20,
        missRate: 20,
        pp: 20,
        maxPp: 20,
        type: 'こうげき',
    },
    {
        name: 'ちょうさ',
        attackUpRate: 1.5,
        deffenceUpRate: 0,
        maxPoint: 10,
        missRate: 10,
        pp: 10,
        maxPp: 10,
        type: 'のうりょくUP',
    },
    {
        name: 'べんきょう',
        attackUpRate: 0,
        deffenceUpRate: 1.5,
        maxPoint: 10,
        missRate: 10,
        pp: 10,
        maxPp: 10,
        type: 'のうりょくUP',
    },
];

const DEFAULT_PLAYER = {
    name: 'みならいプログラマー',
    level: 5,
    hp: 100,
    maxHp: 100,
    attackUpRate: 1,
    deffenceUpRate: 1,
    skills: DEFAULT_PLAYERS_SKILL,
};

const DEFAULT_OPONENT = {
    name: 'SQLインジェクション',
    level: 5,
    hp: 100,
    maxHp: 100,
    attack: 20
};

戦闘フェーズの細かな仕様

ここでは細かなバトル時のコードは省略しますが、ざっくり仕様は以下の通りです。

  • わざのmissRateで、わざが当たるかどうかを確率判定
  • 攻撃力(attack)と攻撃力上昇率(attackUpRate)防御力上昇率(deffenceUpRate)の値を元にダメージ計算
  • モンスターのhpを書き換えて、setStateで更新
  • 途中でモンスターのHPが0になったら、例外を投げてバトル終了ステータスに移行

最後に全てのコードを貼りますので、興味のある方は見てみてください。

戦闘フェーズが完成!

ついに、戦闘ができるようになったので、わざを選んでみます。

スクリーンショット 2022-09-05 12.45.58.jpg

すると、メッセージが切り替わり戦闘フェーズが始まります。
スクリーンショット 2022-09-05 12.46.42.jpg
スクリーンショット 2022-09-05 12.46.43.jpg
スクリーンショット 2022-09-05 12.46.45.jpg
スクリーンショット 2022-09-05 12.46.46.jpg

無事、戦闘メッセージが流れていき、HPの描画もうまくできているようです。

また、戦闘の途中でHPが0になってしまった場合には「たおれてしまった!」のメッセージが表示されるようになりました。

スクリーンショット 2022-09-05 13.03.51.jpg

HPに色をつけてみる

これで戦闘システムはだいたい完成しましたが、さらに完成度を高めるためにUIを磨きます。

今回は、モンスターのHPを色付けして、HPが高い時には緑、低い時には赤色で警告させるようにしてみます。

PlayerとOponentのHP表示分はほとんど同じ処理ですので、HPコンポーネントを作成して共通化させます。

const Hp = (props) => {
    const { hp, maxHp } = props;

    const hpColor = () => {
        if (hp > maxHp * 0.5) {
            return 'green';
        } else if (hp > maxHp * 0.2) {
            return 'orange';
        } else {
            return 'red';
        }
    }
    return (
        <div>
            <span>HP</span>
            <span style={style.hpString}><span style={{ color: hpColor() }}>{hp}</span> / {maxHp}</span>
        </div>)
}

const style = {
    hpString: {
        fontWeight: 'bold',
        marginLeft: '4px',
    }
}

export default Hp;

propsとして渡されるhp, maxHpからHPの残量率を計算し、50%以上なら緑、50%~20%ならオレンジ、20%以下なら赤を返す関数を作り、HPテキストのCSSプロパティに渡します。

スクリーンショット 2022-09-05 13.22.38.jpg

これでモンスターがピンチなのかどうか、一目で判定できるようになりました。

Netlifyで作ったゲームを公開!

せっかくなので、他の人でもリアモンを遊べるようにNetlifyに公開してみました。

公開までの流れはとても簡単です。

まずは、ターミナルでビルドを実行します。

npm run build

Netlifyでプロジェクを作成します。

SitesのAdd new siteからDeploy manuallyを選択

スクリーンショット 2022-09-05 13.11.38.jpg

ローカルに生成されたbuildフォルダをドラッグ&ドロップして、アップするだけで公開できます。

スクリーンショット 2022-09-05 13.11.48.jpg

Netlifyのアカウントを持っていれば、所要時間は1分です。

公開URLはこちら

よかったら遊んでみてください!
https://chimerical-dasik-a1eaca.netlify.app/

まとめ

Reactでポケモン風のRPGゲームを作ってみましたが、学べることがたくさんありやってみてよかったです。
Reactはデータバインディングがデフォルトで使えて、コンポーネント志向でUIを作れるので、ポケモンのようなRPGゲームの制作には最初の仮説通り、向いているなと思いました。

何より、ゲーム作りはとっても楽しい!時間の都合上、モンスターの防御力や素早さのようなパラメーターがなかったり、相手モンスターのわざが1パターンだったり、急所の計算や、HPゲージやなかったりとまだまだ完成度は低いですが、時間を見つけて実装してみたいです。

もしかしたら、次回以降に続きます。

添付資料 ファイル一覧

スクリーンショット 2022-09-05 13.26.38.jpg

src/components/battle/monster/Hp.js

const Hp = (props) => {
    const { hp, maxHp, fontSize } = props;

    const hpColor = () => {
        if (hp > maxHp * 0.5) {
            return 'green';
        } else if (hp > maxHp * 0.2) {
            return 'orange';
        } else {
            return 'red';
        }
    }
    return (
        <div>
            <span>HP</span>
            <span style={{ fontSize: fontSize ? fontSize : 16, ...style.hpString }}><span style={{ color: hpColor() }}>{hp}</span> / {maxHp}</span>
        </div>)

}

const style = {
    hpString: {
        fontWeight: 'bold',
        marginLeft: '4px',
    }
}

export default Hp;

src/components/battle/monster/Image.js

const Image = (props) => {
    const { monster, size, children } = props;
    return (
        <p style={{ opacity: monster.hp === 0 ? 0 : 1, fontSize: size, ...style.image, }}>{children}</p>
    );
}

const style = {
    image: {
        transition: 'opacity 0.5s',
    },
}

export default Image;

src/components/battle/monster/Name.js

const Name = (props) => {
    const { name, level, textAlign } = props;
    return (
        <span style={{ textAlign: textAlign }}>{name}:L{level}</span>
    );
}

export default Name;

src/components/battle/Message.js

import React, { Children, useState } from 'react';
import { STATUS } from "../../constants/battle-constants";

// メッセージコンポーネント
const Message = (props) => {
    const { status, messageText } = props;

    return (
        <div style={style.messageContainer}>
            <MainMessage>{messageText}</MainMessage>
            {status === STATUS.SELECT_MAIN_COMMAND && <MainCommandModal {...props} />}
            {status === STATUS.SELECT_SKILL_COMMAND && <SkillCommandModal {...props} />}
        </div>
    );
}

// 通常のメッセージを表示
const MainMessage = (props) => {
    return (
        <div style={{ ...style.normalMessageContainer, ...style.border }}>
            <p style={{ whiteSpace: 'preWrap' }}>{props.children}</p>
        </div>
    )
}

// メインコマンドを表示
const MainCommandModal = (props) => {
    const { onClickCommands } = props;
    const { onClickFitght, onClickNotFound } = onClickCommands;

    return (<div style={{ ...style.mainCommandContainer, ...style.border }}>
        <MainCommand clickEvent={onClickFitght}>たたかう</MainCommand>
        <MainCommand clickEvent={onClickNotFound}>どうぐ</MainCommand>
        <MainCommand clickEvent={onClickNotFound}>リアモン</MainCommand>
        <MainCommand clickEvent={onClickNotFound}>にげる</MainCommand>
    </div>)
}

const MainCommand = (props) => {
    const { clickEvent, children } = props;
    return (<Command style={style.mainCommandText} onClick={clickEvent}>{children}</Command>);
}

// わざコマンドを表示
const SkillCommandModal = (props) => {
    const { skills, onClickCommands, selectedSkillIndex } = props;
    const { onClickReturnMain, onClickSkill, onSelectSkill } = onClickCommands;
    const skill = skills[selectedSkillIndex];

    return (<>
        <div style={{ ...style.skillComandContainer, ...style.border }}>
            {skills.map((skill, index) =>
                <SkillCommand clickEvent={() => onClickSkill(index)} key={index} style={{ fontWeight: index == selectedSkillIndex ? 'bold' : 'normal' }} >{skill.name}</SkillCommand>
            )}
            <SkillCommand clickEvent={onClickReturnMain}>もどる↩︎</SkillCommand>
        </div>
        {selectedSkillIndex !== null && (
            <div style={{ ...style.skillDetailContainer, ...style.border }}>
                <span style={style.skillPPText}>{skill.pp}/{skill.maxPp}</span>
                <span>{skill.type}わざ</span>
                <Command style={style.skillSelectButton} onClick={onSelectSkill}>けってい</Command>
            </div>
        )}
    </>)
}

const SkillCommand = (props) => {
    const { clickEvent, children } = props;
    return (<Command style={{ ...props.style, ...style.skillCommandText }} onClick={clickEvent}>{children}</Command>);
}

// コマンドの表示
const Command = (props) => {
    const { children, style } = props;
    const [isHovering, setIsHovering] = useState(false);

    const handleMouseEnter = () => {
        setIsHovering(true);
    };

    const handleMouseLeave = () => {
        setIsHovering(false);
    };

    return (<span onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props} style={{ opacity: isHovering ? 0.6 : 1, cursor: 'pointer', ...style }}>{children}</span>);
}

const style = {
    border: {
        border: '2px solid black',
        borderRadius: 4,
        backgroundColor: 'white',
    },
    messageContainer: {
        position: 'relative',
    },
    normalMessageContainer: {
        witdh: '100%',
        height: 80,
        padding: 12,
    },
    mainCommandContainer: {
        position: 'absolute',
        bottom: 0,
        right: 0,
        width: 200,
        height: 80,
        padding: 12,
        display: 'flex',
        flexWrap: 'wrap',
    },
    mainCommandText: {
        width: '50%',
    },
    skillCommandText: {
        width: '100%',
        "&:hover": {
            background: "#efefef"
        },
    },
    skillComandContainer: {
        position: 'absolute',
        bottom: 0,
        display: 'flex',
        flexWrap: 'wrap',
        flexDirection: 'column',
        padding: 12,
        gap: 4,
        width: '50%',
    },
    skillDetailContainer: {
        width: '50%',
        position: 'absolute',
        padding: 12,
        gap: 4,
        bottom: 0,
        right: 0,
        display: 'flex',
        flexWrap: 'wrap',
        flexDirection: 'column',
        alignItems: 'flex-end',
    },
    skillPPText: {
        fontWeight: 'bold',
        fontSize: 18,
    },
    skillSelectButton: {
        fontWeight: 'bold',
    }
}

export default Message;

src/components/battle/Oponent.js

import Hp from './monster/Hp';
import Name from './monster/Name';
import Image from './monster/Image';

const Oponent = (props) => {
    const { oponent } = props;
    return (
        <div style={style.oponentContainer}>
            <div style={style.infoContainer}>
                <Name name={oponent.name} level={oponent.level} textAlign='right' />
                <Hp hp={oponent.hp} maxHp={oponent.maxHp} />
            </div>
            <Image monster={oponent} size={60}>😈</Image>
        </div>
    );
}

const style = {
    infoContainer: {
        witdh: '100%',
        height: '50px',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'left',
        justifyContent: 'center',
        paddingBottom: '12px',
        paddingLeft: '24px',
        borderBottom: '2px solid #000',
        borderLeft: '2px solid #000'
    },
    oponentContainer: {
        display: 'flex',
        justifyContent: 'end',
        justifyContent: 'space-between',
        padding: '24px',
    },
}

export default Oponent;

src/components/battle/Player.js

import Name from './monster/Name';
import Hp from './monster/Hp';
import Image from './monster/Image';

const Player = (props) => {
    const { player } = props;
    return (
        <div style={style.playerContainer}>
            <Image monster={player} size={100}>🧑‍💻</Image>
            <div style={style.infoContainer}>
                <Name name={player.name} level={player.level} textAlign='left' />
                <Hp hp={player.hp} maxHp={player.maxHp} fontSize={18} />
            </div>
        </div>
    );
}

const style = {
    infoContainer: {
        witdh: '100%',
        height: '50px',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'left',
        justifyContent: 'center',
        paddingBottom: '12px',
        paddingRight: '24px',
        borderBottom: '2px solid #000',
        borderRight: '2px solid #000'
    },
    playerContainer: {
        display: 'flex',
        justifyContent: 'start',
        justifyContent: 'space-between',
        alignItems: 'center',
        padding: '12px',
    },
}

export default Player;

src/constants/battle-constants.js

export const STATUS = {
    BATTLE_START: 0,
    SELECT_MAIN_COMMAND: 1,
    SELECT_SKILL_COMMAND: 2,
    ATTACK_PHASE: 3,
    BATTLE_END: 4,
    NOT_FOUND: 999,
}

export const MESSAGE_SPEED = 1500;

src/scene/battle.js

import React, { useState } from 'react';
import Oponent from "../components/battle/Oponent";
import Player from "../components/battle/Player";
import Message from "../components/battle/Message";
import wait from "../utils/wait";
import { STATUS, MESSAGE_SPEED } from "../constants/battle-constants";

const DEFAULT_PLAYERS_SKILL = [
    {
        name: 'かいはつ',
        attack: 10,
        maxPoint: 20,
        missRate: 20,
        pp: 20,
        maxPp: 20,
        type: 'こうげき',
    },
    {
        name: 'ちょうさ',
        attackUpRate: 1.5,
        deffenceUpRate: 0,
        maxPoint: 10,
        missRate: 0,
        pp: 10,
        maxPp: 10,
        type: 'のうりょくUP',
    },
    {
        name: 'べんきょう',
        attackUpRate: 0,
        deffenceUpRate: 1.5,
        maxPoint: 10,
        missRate: 0,
        pp: 10,
        maxPp: 10,
        type: 'のうりょくUP',
    },
];

const DEFAULT_PLAYER = {
    name: 'みならいプログラマー',
    level: 5,
    hp: 100,
    maxHp: 100,
    attackUpRate: 1,
    deffenceUpRate: 1,
    skills: DEFAULT_PLAYERS_SKILL,
};

const DEFAULT_OPONENT = {
    name: 'SQLインジェクション',
    level: 5,
    hp: 100,
    maxHp: 100,
    attack: 20
};

const BattleScene = () => {

    const [status, setStatus] = useState(STATUS.BATTLE_START);
    const [player, setPlayer] = useState(DEFAULT_PLAYER);

    const [oponent, setOponent] = useState(DEFAULT_OPONENT);

    const [selectedSkillIndex, setSelectedSkillIndex] = useState(null);

    const [messageText, setMessageText] = useState(`あ! やせいの\nSQLインジェクションがあらわれた!`);

    // 画面上をクリックしたときの処理
    const onClickHandler = () => {
        switch (status) {
            case STATUS.BATTLE_START:
                goToMainCommand();
                break;

            case STATUS.BATTLE_END:
                if (window.confirm('リトライしますか?')) {
                    window.location.reload();
                }
                break;

            case STATUS.NOT_FOUND:
                goToMainCommand();
                break;

            default:
                break;
        }
    }

    // たたかうを選択したときの処理
    const onClickFitght = () => {
        setStatus(STATUS.SELECT_SKILL_COMMAND);
    }

    // 戻るを選択したときの処理
    const onClickReturnMain = () => {
        goToMainCommand();
    }

    // わざを仮選択したときの処理
    const onClickSkill = (id) => {
        setSelectedSkillIndex(id);
    }

    // わざを確定したときの処理
    const onSelectSkill = () => {

        // stateの更新はラグがあるため、変数に一旦格納
        const tempPlayer = { ...player };
        const tempOponent = { ...oponent };

        // 選択したわざ
        const selectedSkill = tempPlayer.skills[selectedSkillIndex];
        setSelectedSkillIndex(null);

        // わざのPPを減らす
        tempPlayer.skills[selectedSkillIndex].pp --;
        setPlayer(tempPlayer);

        // 時間経過で戦闘を自動送りするため、非同期処理を実行
        Promise.resolve()
            // プレイヤーが攻撃を宣言!
            .then(() => startPlayersAttack())
            .then(() => wait(MESSAGE_SPEED))
            // プレイヤーの攻撃フェーズ
            .then(() => playersAttack())
            .then(() => wait(MESSAGE_SPEED))
            // 敵が攻撃を宣言!
            .then(() => startOponentsAttack())
            .then(() => wait(MESSAGE_SPEED))
            // 敵の攻撃フェーズ
            .then(() => oponentsAttack())
            .then(() => wait(MESSAGE_SPEED))
            // プレイヤーのコマンド選択に戻る
            .then(() => goToMainCommand())
            // 例外発生時 
            .catch(err => handleError(err));

        // プレイヤーが攻撃を宣言!
        const startPlayersAttack = () => {
            setMessageText(` ${player.name}${selectedSkill.name}!`);
            setStatus(STATUS.ATTACK_PHASE);
        }

        // プレイヤーの攻撃フェーズ
        const playersAttack = () => {
            // わざが命中したかどうか
            const isMissed = Math.random() < selectedSkill.missRate / 100;

            if (isMissed) {
                setMessageText(`しかし、はずれてしまった!`);
            } else {
                // 攻撃が当たった場合
                switch (selectedSkill.type) {
                    case 'こうげき':
                        // ダメージ計算
                        const caluculatedDamage = Math.floor(selectedSkill.attack * tempPlayer.attackUpRate);
                        setMessageText(`${player.name}${caluculatedDamage}のダメージ!`);

                        // 攻撃を当てた後のHP計算
                        const afterHp = tempOponent.hp - caluculatedDamage;

                        if (afterHp > 0) {
                            tempOponent.hp = afterHp;
                            setOponent(tempOponent);
                        } else {
                            tempOponent.hp = 0;
                            setOponent(tempOponent);
                            throw new Error('OPONENT_DEAD');
                        }
                        break;

                    case 'のうりょくUP':
                        if (selectedSkill.attackUpRate > 0) {
                            // 攻撃UPわざの場合
                            tempPlayer.attackUpRate = tempPlayer.attackUpRate * selectedSkill.attackUpRate;
                            setPlayer(tempPlayer);
                            setMessageText(`${tempPlayer.name}のこうげきがグーンとあがった!`);
                        } else if (selectedSkill.deffenceUpRate > 0) {
                            // 防御UPわざの場合
                            tempPlayer.deffenceUpRate = tempPlayer.deffenceUpRate * selectedSkill.deffenceUpRate;
                            setPlayer(tempPlayer);
                            setMessageText(`${tempPlayer.name}のぼうぎょがグーンとあがった!`);
                        } else {
                            setMessageText(`しかし、なにもおこらなかった。`);
                        }
                        break;
                    default:
                        throw new Error('INVALID_SKILL_TYPE');
                }
            }
        }

        // 敵が攻撃を宣言!
        const startOponentsAttack = () => {
            setMessageText(`${oponent.name}のこうげき!`);
        }

        // 敵の攻撃フェーズ
        const oponentsAttack = () => {
            // ダメージ計算
            const caluculatedDamage = Math.floor(tempOponent.attack / tempPlayer.deffenceUpRate);
            setMessageText(`${tempPlayer.name}${caluculatedDamage}のダメージ!`);

            // 攻撃を受けた後のHP計算
            const afterHp = tempPlayer.hp - caluculatedDamage;
            if (afterHp > 0) {
                tempPlayer.hp = afterHp;
                setPlayer(tempPlayer);
            } else {
                tempPlayer.hp = 0;
                setPlayer(tempPlayer);
                throw new Error('PLAYER_DEAD');
            }
        }

        const handleError = (err) => {
            switch (err.message) {
                // 敵が倒れたとき、バトル終了
                case 'OPONENT_DEAD':
                    setMessageText(`${oponent.name}をたおした!`);
                    setStatus(STATUS.BATTLE_END);
                    break;
                // プレイヤーが倒れたとき、バトル終了
                case 'PLAYER_DEAD':
                    setMessageText(`${player.name}はたおれてしまった!`);
                    setStatus(STATUS.BATTLE_END);
                    break;
                // その他のエラー
                default:
                    alert(err);
                    break;
            }
        }
    };

    // 未開発のボタンをクリックしたときの処理
    const onClickNotFound = () => {
        setMessageText('この機能はまだできていないのじゃ!');
        setStatus(STATUS.NOT_FOUND);
    }

    // メインコマンド選択に戻る
    const goToMainCommand = () => {
        setStatus(STATUS.SELECT_MAIN_COMMAND);
        setMessageText("どうする?");
    }

    const onClickCommands = {
        onClickFitght,
        onClickReturnMain,
        onClickSkill,
        onClickNotFound,
        onSelectSkill
    };

    return (
        <div style={battleSceneStyle.battleScene} onClick={onClickHandler}>
            <Oponent oponent={oponent} />
            <Player player={player} />
            <Message status={status} onClickCommands={onClickCommands} skills={player.skills} selectedSkillIndex={selectedSkillIndex} messageText={messageText} />
        </div>
    );
}

const battleSceneStyle = {
    battleScene: {
        width: '100%',
        height: '100%',
        padding: '10px',
        boxSizing: 'border-box',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'space-between',
    },
}

export default BattleScene;

src/utils/wait.js

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export default wait;

src/App.js

import logo from './logo.svg';
import './App.css';
import BattleScene from './scene/battle.js';

function App() {
  return (
    <div className="App">
        <BattleScene/>
    </div>
  );
}

export default App;

src/App.css

body {
    background: #f9f9e3;
}
body * {
    box-sizing: border-box;
}
.App {
    width: 380px;
    height: 380px;
    margin: 0;
    background: white;
    border: 1px solid #eee;
}

#root {
    width: 100vw;
    height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
}

p {
    margin: 0;
}

関連記事

よかったら、こちらの記事も読んでみてください。

Electron + Reactでデスクトップアプリを作ろう!
https://qiita.com/udayaan/items/2a7c8fd0771d4d995b69

Electronを使ってMacとWindowsで動くアプリを作ってみる
https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f

話題の最新フロントエンドフレームワーク「Astro」を使ってみた

20
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?