0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React始めて1ヶ月でスイカゲーム作った話

Last updated at Posted at 2025-01-08

導入

吾輩はエンジニアである。経験はまだ無い。
どこで道を間違えたのかとんと見当がつかぬ。何でも薄暗いじめじめした6月にニャーニャー泣きながらReactを始めた事だけは記憶している。吾輩はここで始めてスイカゲームというものを見た。

Q. なぜ作ろうと思ったか?
A. 興味本位

Q. なぜわざわざReactで書いたのか?
A. 本業で利用しているため、作成中に勉強という名目で金銭が発生したから
A. Reactを勉強したいと思ったから!!!

警告
この記事は初心者が独断と偏見で作成したゲームの備忘録です。
Reactのお作法はおろか、物理エンジンの使い方も誤っている恐れがあります。
当ページを参照して発生した問題等については一切責任を負いません。

環境

  • MacBook Air(Apple M2 Pro)
  • React 18.3.1(Latest)
  • Package.json
    • matter-js v0.20.0
    • @types/matter-js v0.19.7

参考にしたサイト様

環境で記載した通り、今回は物理エンジンとしてmatter.jsを利用してみます。
https://nodemand.hatenablog.com/entry/2018/12/07/000338
https://zenn.dev/tadaedo/articles/26a07566f6080f
https://zenn.dev/tadaedo/articles/db1c6ca3f91674

スイカゲームへの道のり

まずは参考に貼ったサイトのコードをパクる。
これでオブジェクトを表示&落下させることはできそう(できるとは言っていない)

参考サイトのコードをパクる

スイカゲームとして利用するオブジェクト情報は一旦別ファイルにまとめておく。
カラーコードは独断と偏見ですが、ポイントは攻略サイトを流用しました。
https://altema.jp/suikagame/rule

オブジェクト情報
suikaData.ts
export const SuikaData = [
    {
        name: "Cherry",
        radius: 10,
        color: "#f10d3d",
        point: 0,
    },
    {
        name: "Strawberry",
        radius: 20,
        color: "#ea4444",
        point: 1,
    },
    {
        name: "Grape",
        radius: 30,
        color: "#781e8c",
        point: 3,
    },
    {
        name: "Dekopon",
        radius: 40,
        color: "#ef9b40",
        point: 6,
    },
    {
        name: "Persimmon",
        radius: 50,
        color: "#cb4b14",
        point: 10,
    },
    {
        name: "Apple",
        radius: 60,
        color: "#ee2929",
        point: 15,
    },
    {
        name: "Pear",
        radius: 70,
        color: "#ccc02e",
        point: 21,
    },
    {
        name: "Peach",
        radius: 80,
        color: "#f59cf5",
        point: 28,
    },
    {
        name: "Pineapple",
        radius: 100,
        color: "#f3c408",
        point: 36,
    },
    {
        name: "Melon",
        radius: 120,
        color: "#71ec77",
        point: 45,
    },
    {
        name: "Watermelon",
        radius: 150,
        color: "#2e7d32",
        point: 55,
    },
];

次に本題のコードですが、基本パクリなので参考までに。
アイコンをImageに変更し、suikaData.tsからランダムに選択したサイズを生成します。

オブジェクトを生成するコード
SuikaGame.tsx
import styled from "styled-components";
import Button from "@mui/material/Button";
import { useCallback, useEffect, useState } from "react";
import Matter, { IBodyDefinition } from "matter-js";

import { SuikaData } from "./suikaData.ts";
import SuikaImage from "./fruit_suika_kurodama.png";

export const SuikaGame = () => {
  const [balls, setBalls] = useState<Matter.Body[]>();
  const width = window.innerWidth;
  const height = window.innerHeight;

  // 物理エンジン本体
  const engine = Matter.Engine.create();
  // 物理エンジンに対して定期的に更新をかけてくれるモジュール
  const runner = Matter.Runner.create();

  // ボディを管理するコンテナ
  // engine.world が全体を管理するコンテナで、その中に壁や球を管理するためのコンテナを追加しているイメージ
  const ballComposite = Matter.Composite.create();
  Matter.Composite.add(engine.world, [
    Matter.Bodies.rectangle(width * 0.5, height * 0.96, width, height * 0.15, {
      isStatic: true,
    }),
    ballComposite,
  ]);

  // ボタンクリック時にアイテムを生成する
  const addCircle = useCallback((num: number) => {
    const x = width / 2 + (Math.random() - 0.5);
    const y = height / 4;
    const circle = Matter.Bodies.circle(x, y, SuikaData[num].radius);
    Matter.Composite.add(ballComposite, circle);
  }, []);

  // エンジン内の状態を変更するCallback関数
  const updater = useCallback(() => {
    // コンポジットから全てのボディを取得
    const allBall = Matter.Composite.allBodies(ballComposite);
    // state に保存。配列を詰め直すことで強制的に更新を発生させる
    setBalls([...allBall]);
  }, [setBalls]);

  // エンジンが開始すると「afterUpdate」が呼ばれるので更新内容を取得してCallback関数を呼ぶ
  useEffect(() => {
    // afterUpdateが呼ばれるたびにコンポジットからボディを取得し、runnerで更新する
    Matter.Events.on(engine, "afterUpdate", updater);
    Matter.Runner.run(runner, engine);
    return () => {
      Matter.Events.off(engine, "afterUpdate", updater);
      Matter.Runner.stop(runner);
    };
  }, []);

  // 当たり判定処理
  const collision = useCallback(
    (event: Matter.IEventCollision<Matter.Events>) => {
      if (!event.pairs) {
        return;
      }
    },
    []
  );

  // 当たり判定イベント取得
  useEffect(() => {
    Matter.Events.on(engine, "collisionStart", collision);
    return () => {
      Matter.Events.off(engine, "collisionStart", collision);
    };
  }, []);

  return (
    <SuikaGameStyle>
      <>
        <h1 className="h1-css">Suika Game</h1>
        <div
          className="div"
          style={{
            height: height * 0.8,
            width: width * 0.8,
          }}
        >
          {balls &&
            balls.map((body) => {
              if (!body.parts) return null;
              const part = body.parts[0];
              return (
                <div
                  key={part.id}
                  style={{
                    alignItems: "center",
                    justifyContent: "center",
                    position: "absolute",
                    top: part.position.y - part.circleRadius!,
                    left: part.position.x - part.circleRadius!,
                    width: part.circleRadius! * 2,
                    height: part.circleRadius! * 2,
                    borderRadius: part.circleRadius,
                  }}
                >
                  <img
                    src={SuikaImage}
                    width={part.circleRadius! * 1.9}
                    height={part.circleRadius! * 2}
                    alt=""
                  />
                </div>
              );
            })}
          <Button
            onClick={() => addCircle(Math.floor(Math.random() * 10))}
            style={{ textAlign: "left" }}
          >
            Add Suika
          </Button>
        </div>
      </>
    </SuikaGameStyle>
  );
};

const SuikaGameStyle = styled.div`
  //背景の設定
  background-color: rgba(129, 74, 41, 0.55);
  background-position: center;
  background-repeat: no-repeat;
  height: 100vh;
  width: 100vw;

  .header-class {
    top: 0;
    left: 0;
    width: 100%;
    font-size: 170%;
    flex: 1;
    display: flex;
    justify-content: center;
  }

  .h1-css {
    margin-top: 0;
    text-align: center;
  }

  .div {
    flex: 1;
    align-items: center;
    justify-content: center;
    border-color: black;
    margin: auto;
    background-color: rgba(255, 231, 108, 0.66);
  }
`;

suika1.gif
うーん、いい感じかも🤔

壁建設株式会社

上記のコードでは以下を実現しました。

  • クリック毎にランダムなオブジェクトが生成される
  • 生成されたオブジェクトは保持される
  • 生成されたオブジェクトは自由落下する
  • 床の判定

suika2.gif
そうです、壁がないんです。
スイカゲームって箱の中にフルーツ入れるから壁作らないとダメなんですよね。

無理やり壁を作る
SuikaGame.tsx
// ボディを管理するコンテナ
  // engine.world が全体を管理するコンテナで、その中に壁や球を管理するためのコンテナを追加しているイメージ
  const ballComposite = Matter.Composite.create();
  Matter.Composite.add(engine.world, [
    Matter.Bodies.rectangle(width * 0.5, 100, width, 10, {
      isStatic: true,
      label: "gameOver",
    }), // 上
    Matter.Bodies.rectangle(width * 0.5, height, width, height * 0.15, {
      isStatic: true,
    }), // 下
    Matter.Bodies.rectangle(0, height, width * 0.2, height * 1.5, {
      isStatic: true,
    }), // 左
    Matter.Bodies.rectangle(width, height, width * 0.2, height * 1.5, {
      isStatic: true,
    }), // 右
    // 内部要素の定義
    ballComposite,
  ]);

やり方が正しいかはさておき、両サイドに無理やりブロック作って落ちないようにしました。レスポンシブ?なにそれ美味しいの??
ついでにLabelを活用して、上の壁(つまり天井)に触れるとゲームオーバーになる実装を入れてみたいと思います。

ゲームオーバーの実装
SuikaGame.tsx
    const [isGameOver, setGameOver] = useState(false);
    // 当たり判定処理
    const collision = useCallback(
        (event: Matter.IEventCollision<Matter.Events>) => {
            if (!event.pairs) {
                return;
            }
            event.pairs.forEach((pair: Matter.Pair) => {
                // 衝突したボディが上に衝突したらゲームオーバー
                if (pair.bodyA.label == "gameOver") {
                    setGameOver(true);
                }
            });
        },
        []
    );

ゲームーオーバーになった際は、isGameOverフラグで判定してアニメーションでも入れると良いでしょう。

suika3.gif
だいぶそれっぽいですね〜🤔

ゲーム性を植え付ける

さて、箱が完成してオブジェクトたちも心なしか嬉しそうです。
ゲームオーバーを作ったので、次はゲーム性を加える必要がありますね。

同じサイズでマージしたい
SuikaGame.tsx
const [score, setScore] = useState(0);
// ボタンクリック時にアイテムを生成する
    const addCircle = useCallback((num: number) => {
        const x = width / 2 + (Math.random() - 0.5);
        const y = height / 4;
        const options: IBodyDefinition = {
            label: SuikaData[num].name,
        };
        const circle = Matter.Bodies.circle(x, y, SuikaData[num].radius, options);
        Matter.Composite.add(ballComposite, circle);
    }, []);

// 当たり判定処理
  const collision = useCallback(
    (event: Matter.IEventCollision<Matter.Events>) => {
      if (!event.pairs) {
        return;
      }
      event.pairs.forEach((pair: Matter.Pair) => {
        // 同じLabelのBallが衝突したらマージする
        const { bodyA, bodyB } = pair;
        if (bodyA.label === bodyB.label) {
          if (bodyA.label === "Watermelon") {
            setScore((prev) => prev + suikaData[10].point);
          } else {
            suikaData.forEach((data, index) => {
              if (data.name == bodyA.label) {
                addCircle(bodyA.position.x, bodyA.position.y, index + 1);
                setScore((prev) => prev + suikaData[index].point);
              }
            });
          }
          Matter.Composite.remove(ballComposite, bodyA);
          Matter.Composite.remove(ballComposite, bodyB);
        }

        // 衝突したボディが上に衝突したらゲームオーバー
        if (pair.bodyA.label == "gameOver") {
          setGameOver(true);
        }
      });
    },
    []
  );

はい、スイカゲームの核とも言える機能ですね。
これで同じサイズのオブジェクトがぶつかった際にマージされます。オブジェクトの種類を特定するために名前をLabelとして持たせておくのもポイントです。
ついでにスコアも計算してあげましょう。

完成

厳密には完成していないのですが、飽きてしまったReactが大体書けるようになってきたのでこれでいいかなと思ってしまいました(笑)

完成系
SuikaGame.tsx
import styled from "styled-components";
import Button from "@mui/material/Button";
import {useCallback, useEffect, useState} from "react";
import Matter, {IBodyDefinition} from "matter-js";

import {SuikaData} from "./suikaData.ts";
import SuikaImage from "./fruit_suika_kurodama.png";
import GameOver from "../suika/gameOver.tsx";

export const SuikaGame = () => {
    const [balls, setBalls] = useState<Matter.Body[]>();
    const [isGameOver, setGameOver] = useState(false);
    const [score, setScore] = useState(0);

    const width = window.innerWidth;
    const height = window.innerHeight;

    // 物理エンジン本体
    const engine = Matter.Engine.create();
    // 物理エンジンに対して定期的に更新をかけてくれるモジュール
    const runner = Matter.Runner.create();

    // ボディを管理するコンテナ
    // engine.world が全体を管理するコンテナで、その中に壁や球を管理するためのコンテナを追加しているイメージ
    const ballComposite = Matter.Composite.create();
    Matter.Composite.add(engine.world, [
        Matter.Bodies.rectangle(width * 0.5, 100, width, 10, {
            isStatic: true,
            label: "gameOver",
        }), // 上
        Matter.Bodies.rectangle(width * 0.5, height * 0.96, width, height * 0.15, {
            isStatic: true,
        }), // 下
        Matter.Bodies.rectangle(0, height, width * 0.2, height * 1.5, {
            isStatic: true,
        }), // 左
        Matter.Bodies.rectangle(width, height, width * 0.2, height * 1.5, {
            isStatic: true,
        }), // 右
        // 内部要素の定義
        ballComposite,
    ]);

    // ボタンクリック時にアイテムを生成する
    const addCircle = useCallback(
        (tmpX: number | null, tmpY: number | null, num: number) => {
            const x = tmpX ?? width / 2 + (Math.random() - 0.5);
            const y = tmpY ?? height / 3;
            const options: IBodyDefinition = {
                label: SuikaData[num].name,
            };
            const circle = Matter.Bodies.circle(x, y, SuikaData[num].radius, options);
            Matter.Composite.add(ballComposite, circle);
        },
        []
    );

    // エンジン内の状態を変更するCallback関数
    const updater = useCallback(() => {
        // コンポジットから全てのボディを取得
        const allBall = Matter.Composite.allBodies(ballComposite);
        // state に保存。配列を詰め直すことで強制的に更新を発生させる
        setBalls([...allBall]);
    }, [setBalls]);

    // エンジンが開始すると「afterUpdate」が呼ばれるので更新内容を取得してCallback関数を呼ぶ
    useEffect(() => {
        // afterUpdateが呼ばれるたびにコンポジットからボディを取得し、runnerで更新する
        Matter.Events.on(engine, "afterUpdate", updater);
        Matter.Runner.run(runner, engine);
        return () => {
            Matter.Events.off(engine, "afterUpdate", updater);
            Matter.Runner.stop(runner);
        };
    }, []);

    // 当たり判定処理
    const collision = useCallback(
        (event: Matter.IEventCollision<Matter.Events>) => {
            if (!event.pairs) {
                return;
            }
            event.pairs.forEach((pair: Matter.Pair) => {
                // 同じLabelのBallが衝突したらマージする
                const {bodyA, bodyB} = pair;
                if (bodyA.label === bodyB.label) {
                    if (bodyA.label === "Watermelon") {
                        setScore((prev) => prev + SuikaData[10].point);
                    } else {
                        SuikaData.forEach((data, index) => {
                            if (data.name == bodyA.label) {
                                addCircle(bodyA.position.x, bodyA.position.y, index + 1);
                                setScore((prev) => prev + SuikaData[index].point);
                            }
                        });
                    }
                    Matter.Composite.remove(ballComposite, bodyA);
                    Matter.Composite.remove(ballComposite, bodyB);
                }

                // 衝突したボディが上に衝突したらゲームオーバー
                if (pair.bodyA.label == "gameOver") {
                    setGameOver(true);
                }
            });
        },
        []
    );

    // 当たり判定イベント取得
    useEffect(() => {
        Matter.Events.on(engine, "collisionStart", collision);
        return () => {
            Matter.Events.off(engine, "collisionStart", collision);
        };
    }, []);

    return (
        <SuikaGameStyle>
            <>
                {isGameOver ? <GameOver score={score}/> : null}
                <h1 className="h1-css">Suika Game</h1>
                <header className="header-class">
                    <h3 className="h3-css">Score:{score}</h3>
                </header>
                <div
                    className="div"
                    style={{
                        height: height * 0.8,
                        width: width * 0.8,
                    }}
                >
                    {balls &&
                        balls.map((body) => {
                            if (!body.parts) return null;
                            const part = body.parts[0];
                            return (
                                <div
                                    key={part.id}
                                    style={{
                                        alignItems: "center",
                                        justifyContent: "center",
                                        position: "absolute",
                                        top: part.position.y - part.circleRadius!,
                                        left: part.position.x - part.circleRadius!,
                                        width: part.circleRadius! * 2,
                                        height: part.circleRadius! * 2,
                                        borderRadius: part.circleRadius,
                                    }}
                                >
                                    <img
                                        src={SuikaImage}
                                        width={part.circleRadius! * 1.9}
                                        height={part.circleRadius! * 2}
                                        alt=""
                                    />
                                </div>
                            );
                        })}
                    <Button
                        onClick={() =>
                            addCircle(
                                width / 3 + (Math.random() - 0.5),
                                null,
                                Math.floor(Math.random() * 10)
                            )
                        }
                        style={{textAlign: "left"}}
                    >
                        Add Left SuikaGame
                    </Button>
                    <Button
                        onClick={() =>
                            addCircle(
                                width / 2 + (Math.random() - 0.5),
                                null,
                                Math.floor(Math.random() * 10)
                            )
                        }
                        style={{textAlign: "center"}}
                    >
                        Add Center SuikaGame
                    </Button>
                    <Button
                        onClick={() =>
                            addCircle(
                                width / 1.5 + (Math.random() - 0.5),
                                null,
                                Math.floor(Math.random() * 10)
                            )
                        }
                        style={{textAlign: "right"}}
                    >
                        Add Right SuikaGame
                    </Button>
                </div>
            </>
        </SuikaGameStyle>
    );
};

const SuikaGameStyle = styled.div`
    //背景の設定
    background-color: rgba(129, 74, 41, 0.55);
    background-position: center;
    background-repeat: no-repeat;
    height: 100vh;
    width: 100vw;
    
    .header-class {
        top: 0;
        left: 0;
        width: 100%;
        font-size: 170%;
        flex: 1;
        display: flex;
        justify-content: center;
    }

    .h1-css {
        margin-top: 0;
        margin-bottom: 0;
        text-align: center;
    }

    .h3-css {
        margin-top: 0;
        margin-bottom: 0;
        text-align: center;
    }

    .div {
        flex: 1;
        align-items: center;
        justify-content: center;
        border-color: black;
        margin: auto;
        background-color: rgba(255, 231, 108, 0.66);
    }
`;

気持ちボタンを3つ配置して、スライドしなくても許してもらおうという魂胆があります。

suika4.gif

以下が残課題ですが、いつか気が向いた際にダイジェストでお届けします。たぶん。

  • クリックした場所にオブジェクトを生成する機能
    • 面倒なので3箇所で許して欲しい
  • オブジェクトの種類毎に色やアイコンを変更する
    • 気が向いた時にやる(?)

以上、これで俺もReactマスターだ!!!!
※本業では物理エンジンを利用しません。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?