【アプリ開発⑤】簡単なボールゲームを作ってみた(Catch The Ball)

どうも、Shinです。

 

今回は「ゲーム開発」です。

こちらのサイトで、とてもわかりやすいボールゲーム開発講座をみつけました。

有料級のかなり分かりやすい教材だと思ったので、早速実践です。

 

なぜ、ボールゲームを作ってみようと思ったのかと言うと。

  • ゲームを1から作ってみたかったから
  • ゲーム制作の流れを掴むことができると思ったから
  • アプリ練習の一貫として

ということで、早速コードと学んだことを書いていきます。

完成品・コード紹介

スタート画面

 

メイン画像

リザルト

ということで、お次はコードです。

スタート画面のコード


package com.example.catchtheball;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.content.Intent;

public class StartActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_start_avtivity);



        Button startGame;
        startGame = findViewById(R.id.startGame);

        startGame.setOnClickListener(new View.OnClickListener(){
            @Override
            public void onClick(View view){
                Intent intent = new Intent(StartActivity.this, MainActivity.class);
                startActivity(intent);
            }
        });
    }
}

スタート画面のコードです。

ボタン操作の実装(startGame.setOnClickListener)して、ボタンを押すとメイン画面に遷移する(intent)を実装しただけです。

Intentの第一引数には現在のアクティビティ(startActivity)、第二引数には移動先のアクティビティ(MainActivity)を指定します。これは最もよく使われる明示的コンストラクタですね。これはぜひとも覚えておきましょう。

メイン画面のコード


package com.example.catchtheball;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.media.MediaPlayer;
import android.view.Display;
import android.view.View;
import android.os.Bundle;
import android.os.Handler;
import android.view.MotionEvent;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import android.view.WindowManager;
import android.view.Display;
import android.graphics.Point;
import java.util.TimerTask;
import java.util.Timer;
import android.content.Intent;

public class MainActivity extends AppCompatActivity {


    private TextView scoreLabel;
    private TextView startLabel;
    private ImageView box;
    private ImageView orange;
    private ImageView pink;
    private ImageView black;

    private int frameHeight;
    private int boxSize;
    private int screenWidth;
    private int screenHeight;

    private float boxY;
    private float orangeX;
    private float orangeY;
    private float pinkX;
    private float pinkY;
    private float blackX;
    private float blackY;

    //Speed
    private int boxSpeed;
    private int orangeSpeed;
    private int pinkSpeed;
    private int blackSpeed;

    //score
    private int score = 0;

    //Handler & Timer
    private Handler handler = new Handler();
    private Timer timer = new Timer();
    private boolean action_flg = false;
    private boolean start_flg = false;

    //Sound
    private SoundPlayer soundPlayer;
    private MediaPlayer mediaPlayer;





    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        soundPlayer = new SoundPlayer(this);

        scoreLabel = findViewById(R.id.scoreLabel);
        startLabel = findViewById(R.id.startLabel);
        box = findViewById(R.id.box);
        orange = findViewById(R.id.orange);
        pink = findViewById(R.id.pink);
        black = findViewById(R.id.black);

        //Screen Size
        WindowManager wm = getWindowManager();
        Display display = wm.getDefaultDisplay();
        Point size = new Point();
        display.getSize(size);

        screenWidth = size.x;
        screenHeight = size.y;

        //speed
        boxSpeed = Math.round(screenHeight / 60f);
        orangeSpeed = Math.round(screenWidth / 60f);
        pinkSpeed = Math.round(screenWidth / 36f);
        blackSpeed = Math.round(screenWidth / 45f);

        orange.setX(-80.0f);
        orange.setY(-80.0f);
        pink.setX(-80.0f);
        pink.setY(-80.0f);
        black.setX(-80.0f);
        black.setY(-80.0f);

        scoreLabel.setText("Score : 0");
    }





    //ballの動きを決定
    public void changePos() {

        hitCheck();

        //Orange
        orangeX -= orangeSpeed;
        if(orangeX < 0) {
            orangeX = screenWidth + 20;
            orangeY = (float)Math.floor(Math.random() * (frameHeight - orange.getHeight()));
        }
        orange.setX(orangeX);
        orange.setY(orangeY);

        //Black
        blackX -= blackSpeed;
        if(blackX < 0){
            blackX = screenWidth + 10;
            blackY = (float)Math.floor(Math.random() * (frameHeight) - black.getHeight());
        }
        black.setX(blackX);
        black.setY(blackY);

        //Pink
        pinkX -= pinkSpeed;
        if(pinkX < 0){
            pinkX = screenWidth + 5000;
            pinkY = (float)Math.floor(Math.random() * (frameHeight) - (pink.getHeight()));
        }
        pink.setX(pinkX);
        pink.setY(pinkY);

        //Box
        if (action_flg == true) {
            boxY -= boxSpeed;
        } else {
            boxY += boxSpeed;
        }


        if(boxY < 0) boxY = 0; if(boxY > frameHeight - boxSize) boxY = frameHeight - boxSize;

        box.setY(boxY);

        scoreLabel.setText("Score : " + score);
    }






    //衝突定義とスコア計算
    public void hitCheck() {
        //orange
        float orangeCenterX = orange.getWidth() / 2 + orangeX;
        float orangeCenterY = orange.getHeight() / 2 + orangeY;

        if (hitStatus(orangeCenterX,orangeCenterY)) {
            orangeX = -10.0f;
            score += 10;
            soundPlayer.playHitSound();
        }

        //pink
        float pinkCenterX = pink.getWidth() / 2 + pinkX;
        float pinkCenterY = pink.getHeight() / 2 + pinkY;

        if (hitStatus(pinkCenterX,pinkCenterY)) {
            pinkX = -10.0f;
            score += 30;
            soundPlayer.playHitSound();
        }

        //black
        float blackCenterX = blackX + black.getWidth() / 2;
        float blackCenterY = blackY + black.getHeight() / 2;

        if (hitStatus(blackCenterX,blackCenterY)) {
            soundPlayer.playOverSound();
            mediaPlayer.stop();
            mediaPlayer = MediaPlayer.create(this,R.raw.zannense);
            mediaPlayer.setVolume(1.0f, 1.0f);
            mediaPlayer.start();

            //gameover
            if (timer != null) {
                timer.cancel();
                timer = null;
            }
            //結果画面へ
            Intent intent = new Intent(getApplicationContext(),ResultActivity.class);
            intent.putExtra("SCORE",score);
            startActivity(intent);
        }
    }




    //共通部分を関数でまとめた
    public boolean hitStatus(float centerX, float centerY){
        return(0 <= centerX && centerX <= boxSize && boxY <= centerY && centerY <= boxY + boxSize) ? true : false;
    }







    //画面をタッチしたときの動き(青いboxの動き)
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (start_flg == false) {

            start_flg = true;

            FrameLayout frame = findViewById(R.id.frame);
            frameHeight = frame.getHeight();

            boxY = box.getY();
            boxSize = box.getHeight();

            startLabel.setVisibility(View.GONE);

            //BGM
            mediaPlayer = MediaPlayer.create(this,R.raw.game_maoudamashii_7_event45);
            mediaPlayer.setLooping(true);
            mediaPlayer.setVolume(8.0f, 8.0f);
            mediaPlayer.start();

            timer.schedule(new TimerTask() {
                @Override
                public void run() {
                    handler.post(new Runnable() {
                        @Override
                        public void run() {
                            changePos();
                        }
                    });
                }
            }, 0, 20);
        } else {
            if (event.getAction() == MotionEvent.ACTION_DOWN) {
                action_flg = true;
            } else if (event.getAction() == MotionEvent.ACTION_UP) {
                action_flg = false;
            }
        }
        return true;
    }



    //戻るボタンの無効化
    @Override
    public void onBackPressed() {
    }
}

Mainの記述が1番長いです。

実装してるものを箇条書きしてみました👇

  • ボール・ボックス画像の読み込み
  • ボールのスピードと動き
  • 衝突定義とスコア
  • スマホの画面の大きさを取得
  • タッチ時間のためのタイマー設置
  • サウンドの読み込み(効果音やBGM)
  • 結果画面へ遷移
  • 戻るボタンの無効化

今まではボタン実装とインテントくらいしか経験はなかったのですが、ここでは「ボールの動きからタイマーの設置」「画面大きさの取得」から「サウンドの読み込み」まで学ぶことができました。

画面タッチの実装は「onTouchEvent」を使います。このメソッドの中にボックスとボールを動かすメソッドがさらに入れ子で入ってます(changePosの部分)。

changePosのメソッドの中にボールを動かすカラクリが書かれています。なお、run()を繰り返し呼び出すためにTimeshedureの第三引数を20としています。これで20ミリ秒ごとにTimerの第一引数の処理を行うことができます。

つまり、TImerTask()を何度でも呼び出しているということです。これがボールを動かすためには欠かせない処理になるわけですね。

リザルト画面のコード


package com.example.catchtheball;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.widget.TextView;
import java.util.ServiceConfigurationError;
import android.content.SharedPreferences;
import android.widget.Button;
import android.view.View;
import android.content.Intent;

public class ResultActivity extends AppCompatActivity {


    Button tryAgain;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_result);

        TextView scoreLabel = findViewById(R.id.scoreLabel);
        TextView highScoreLabel = findViewById(R.id.highScoreLabel);
        Button tryAgain = findViewById(R.id.tryAgain);

        //Mainで取得したスコアを取得
        int score = getIntent().getIntExtra("SCORE", 0);
        scoreLabel.setText(score + "");


        //ハイスコアを記録するデータベースの作成
        SharedPreferences sharedPreferences = getSharedPreferences("GAME_DATA", MODE_PRIVATE);
        int highScore = sharedPreferences.getInt("HIGH_SCORE", 0);

        if (score > highScore) {
            highScoreLabel.setText("High Score :" + score);

            SharedPreferences.Editor editor = sharedPreferences.edit();
            editor.putInt("HIGH_SCORE", score);
            editor.apply();

        } else {
            highScoreLabel.setText("High Score" + highScore);
        }


        tryAgain.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Intent intent = new Intent(ResultActivity.this, MainActivity.class);
                startActivity(intent);
            }
        });
    }

        @Override
        public void onBackPressed () {
        }
}

リザルト画面ではゲームで獲得したスコアを表示しています。

メインで取得したスコアをリザルトで取得するにはgetIntent().getIntExtra(“SCORE”,0);で受け取っています。第一引数にはメインで指定したIDを入れるだけですね。Intが使われているのは数値だからです。

そこで同時にハイスコアを保存するSharedPrefarencedクラスを使用しています。簡易的なデータを保存するときに利用するそうです。メモ帳アプリではSQliteを利用しましたが、ここではそこまで用意する必要はないらしいですね。

あとは、Tryボタンです。再挑戦するためにIntentでメイン画面に戻るコードを書いています。ここは何もみずとも記述ができました。

サウンド管理のコード


package com.example.catchtheball;

import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.SoundPool;
import android.content.Context;

public class SoundPlayer {

private static SoundPool soundPool;
private static int hitSound;
private static int overSound;
private AudioAttributes audioAttributes;

//効果音設定
public SoundPlayer(Context context) {
soundPool = new SoundPool(2, AudioManager.STREAM_MUSIC, 0);
hitSound = soundPool.load(context, R.raw.hit, 1);
overSound = soundPool.load(context, R.raw.over, 1);
}

//スコアが出るballとの衝突サウンド
public void playHitSound() {
soundPool.play(hitSound, 1.0f, 1.0f, 1, 0, 1.0f);
}
//黒いボールとの衝突サウンド
public void playOverSound() {
soundPool.play(overSound, 1.0f, 1.0f, 1,0,1.0f);
}
}

このjavaクラスを作ったのは今回は初めてです。

ボールとボックスが衝突したときの効果音をつけるために「SoundPool」と呼ばれるクラスを利用しました。短い音だとSoundPoolで十分だそうです。

読み込むときはsoundpool.loadメソッドを使います。

逆に長いBGMになると、Mainでも使っている「MediaPlayer」クラスを使えばいいです。ここは自力で検索して調べました。詳細は後述します。

今回の企画で学んだこと

たくさんあります。

画面が重なる仕様のときは「FrameLayout」

これはMainのレイアウトの話です。

今回のゲームではルール上、青いボックスとボールが衝突するというギミックを入れるので「FrameLayout」を使いました。

画像が重なるときはこのレイアウトを使えばいいとのこと。これをうまく使いこなせれば、同じ画面で画面の切り替えもできることができるらしいです。詳細は不明ですが。

画面をタップしている間、何度もメソッドを呼び出す仕様(Timerクラス)

画面をタップしたときの処理はboolean型の「onTouchEvent(MoutionEvent event)」で行います。これはボタンを押したときのsetOnCilckLisnterner()と同じような感覚で実装できました。

今回の収穫は「Timerクラスのschedule(TimerTask task, long delay, long period)」です。

TimerTask task 実行する処理(タスク)
long delay タスクを実行するまでに待機する時間(ミリ秒で指定)
long period タスクの実行間隔(ミリ秒で指定)

TimerTask(){}の中に、ボタンを押している間の動作を盛り込みます。今回はChangePos()という自作の関数でボールを動かす内容を記述しています。

タスクを実行するまでの待機時間はないので第二引数のlong delayは「0」で、繰り返す時間感覚はできるだけ短くしてlong periodを「20」として20ミリ秒感覚でTimerTaskを実行するようにしています。

for文などの繰り返し文を書かずとも、繰り返しができると知って少し驚いた。詳しくはこちら→(https://codeforfun.jp/android-studio-catch-the-ball-4/

「メインスレッド外で UI を変更することはできない」について

Androidアプリにおいて、メイン(UI)スレッド以外のスレッドで、UIを変更しようとするとエラーが発生します。メインメソッドのUI(この場合はスコアラベル)を変更するためには、TimerTaskのスレッドでは変更できないということです。(http://accelebiz.hatenablog.com/entry/2016/09/01/061934

そこで、メインスレッドにRunnableを渡す方法が「handler」です。

handlar.post(new Runnable()){}でメインスレッドでChangePosを実行することができます。これによってメインスレッドにおけるUIを変更することができます。

ここら辺は、初心者の私にとってはちょっと理解がしにくい箇所でした。これだけの例では感覚が掴めないので、また出てきたときに理解を深めようと思います。

(Runnableとかスレッドについての理解も不十分だと感じています。スレッドはコンビニの店員みたいな感じで、タスクを複数同時に処理できるようにするために使うというぼんやりとした理解で終わっています。)

MotionEvent.ACTION_DOWN(ACTION_UP)

これは「どのように動くか?」の条件文で使う指示です。

ACTION_DOWNが「画面をタッチしている状態」のことで、今回のゲームでは長押し機能を使うので、それを指示するために使っています。(押している間はボックスが上にあがる)。

ACTION_UPが「画面を離した状態」のことで「画面から指を離したとき」と条件文に書き込むときに使いました。


if(getEvent() == MotionEvent.ACTION_DOWN){
   action_flag = true;
}

画面をタッチしたときにアクションフラグを立てるという感じでコードを書いています。

Framelayoutの高さを取得する方法

画面(View)の高さは「View.getHeight()」で取得することができます。

今回のゲームだと 「orange.getHeiight()」「pink.getHeight()」「frame.getHeight」等で、ボールや画面の高さを取得しています。これは動きに制限をつけるためには必須でしょう。


Frame frame = findViewByID(R.id.frame);
frameHeight = frame.getHeight();

これでフレーム(枠組み)の高さを取得しています。

アプリ画面サイズ関連は「WindowManager」

ゲーム画面のサイズを取得するためのコーディングです。


WindowManager wm = getWindowManager;
DisPlay display = wm.getDefaultDisplay;
Point size = new Point();
display.getSize(size);

getDefaultDisplay(WindowManagerのパブリックメソッド)でFrameLayoutの画面サイズを取得することができます。PointとDisplayクラスを併用してgetSize(画像をピクセル単位で読み取る)で呼び出せば画面サイズの取得が完了です。

https://developer.android.com/reference/android/view/WindowManager

Math.random()でランダムに出現させる

ボールがX<0のときは画面の右側のY座標からランダムで出現させるためのメソッドです。


if(orangeX< 0){
 orangeX = ScreenWidth + 20;
 orangeY = (float)Math.floor(Math.random() * (Frame.Height - orange.Height()));

orange.setX(orangeX);
orange.setY(orangeY);
}

ここでポイントなのが、Math.floor()の中身です。

Math.random() * x;で0〜xまでの値をランダムで出力してくれます。これでY座標からランダムにボールが出現してくれると言うわけですね。これでゲームがより面白くなりました。

データベースはSharedPreferencesを使った

前述したように、簡単なデータ保存ならば「SharedPreferences」を使えばいいようです。


SharedPreferences sharedPreferences = getPreferences("GAME_DATA",PRIVATE);
int highScore = sharedPreferences.getInt("HIGH_SCORE", 0);

if(score > highScore){
  HighScoreLabel.setText("High Score:"+ score);

  SharedPreferences.Editor editor = SharedPreferences.edit(); 
  editor.putInt("HIGH_SCORE",score);
  editor.apply();
}

getPreferences()の第一引数にはデータベース名、第二引数にはデータベースを保存する形態を指定します。今回の場合はオフラインでデータを保存するのでPRIVATEでOKです。

データベースを編集するときはEdit()を利用します。これでデータベースに新しいデータを書き込んだりできるようになります。今回はハイスコアをのデータをデータベースに書き込むなどの操作をしていますね。

短い効果音やサウンドはSoundPoolクラスを使う

ゲームで欠かせない効果音を入れる作業です。サウンド用に別javaファイルを用意しました。

public SoundPlayer(Context context){
soundpool = new SoundPool(2, AudioManeger.STREAM_MUSIC, 0);
hitsound = soundpool.load(2,R.raw.hit,1);
oversound = sound.load(2,R.raw.over,1);
}

public void Playhitsound(){
  soundpool.play(hitsound, 1.0f, 1.0f ,1 ,0  ,1.0f);
}

public void Playoversound(){
  soundpool.play(oversound, 1.0f, 1.0f ,1 ,0 ,1.0f)
}

SoundPlayerクラスを作って、その中にPlayhitsound()関数とPlayoversound()関数を作っておきます。
publicなので、どのjavaクラスでも呼び出せるので、Mainクラスでこの当たったときの効果音を呼び出すようにしています。

感想・気づいたこと

今回は「https://codeforfun.jp/android/game-catch-the-ball/」さんのサイトにお世話になりました。第1回〜第12回まで、丁寧にボールゲームのアプリ作成法を説明していて、とても参考なりました。

アプリゲーム初心者の私でもサクサク理解しながらコードを書くことができました。とても良心的なサイトだと思います。初心者なら駆け出しとしては必見だと思いました。

 

コードはほぼ写経で理解しながらぽちぽち打ち込んでいきましたが、4日ほどかかってようやく完成しました。特に苦戦したところはボールとボックスの座標設置ですかね。FrameLayoutのサイズを取得して、それを図にイメージして作成しないととてもじゃないけど開発は難しいと感じました。

また、画像の動かし方やランダム性、スコアの加算からデータベースへの保存まで多岐に渡って学習することができたので大満足の企画でした。

 

ただ、サイトの情報だけだと背景もBGMもなくて味気ないと感じたので、自力で「BGMと背景画像」を追加しました。MediaPlayerを利用してゲーム中の音楽とゲームオーバー になったときの効果音を実装できたのは嬉しかったですね。自分で調べて実装できた感があってうれしかったです。

次回は、できるだけ何も見ずに「シューティングゲーム」でも作ってみようと思います。途中で挫折したら別企画やります。とにかく躓いてプログラミングをやらなくなるのが怖いので、とにかく難易度を下げつつ、新しいコード作成に取り掛かりたいと思います。

それではこれで。

(執筆時間3h40min)

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です