국비교육(22-23)

74일차(2)/Android App(26) : View 로 미니 슈팅게임 만들기(4)

서리/Seori 2023. 1. 20. 18:06

74일차(2)/Android App(26) : View 로 미니 슈팅게임 만들기(4)

 

- 사운드 적용

- 사운드 끄기 기능

 

 

SoundManager 클래스

package com.example.step09gameview;

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

import java.util.HashMap;
import java.util.Map;

/*
    효과음을 필요한 시점에 재생하기 위한 클래스 설계
 */
public class SoundManager {
    //사운드의 아이디값(정수값) 을 저장하기 위한 Map
    Map<Integer, Integer> map=new HashMap<>();
    //SoundPool
    SoundPool pool;
    //Context
    Context context;
    //볼륨
    int streamVolume;
    //필드
    boolean isMute=false;

    //생성자
    public SoundManager(Context context){
        this.context=context;
        pool=new SoundPool(1, AudioManager.STREAM_MUSIC, 0);
        //오디어 서비스 객체를 얻어와서
        AudioManager am=(AudioManager)
                context.getSystemService(Context.AUDIO_SERVICE);
        //설정된 음악 볼륨값을 읽어와서 필드에 저장한다.
        streamVolume=am.getStreamVolume(AudioManager.STREAM_MUSIC);
    }
    //무음인지 여부를 전달받는 메소드
    public void setMute(boolean mute) {
        isMute = mute;
    }

    //재생할 사운드 등록하는 메소드
    public void addSound(int key, int resId){
        //resId 를 이용해서 사운드를 로딩하고 아이디값을 리턴 받는다.
        int soundId=pool.load(context, resId, 1);
        //리턴 받은 아이디값을 인자로 전달된 키값으로 저장한다
        map.put(key, soundId);
    }
    //사운드를 재생하는 메소드
    public void playSound(int key){
        //만일 무음 모드면 여기서 메소드 끝내기
        if(isMute)return;
        //인자로 전달받은 키값을 이용해서 Map 에서 재생할 사운드의 아이디를 읽어온다.
        int soundId=map.get(key);
        //재생하기
        pool.play(soundId, streamVolume, streamVolume, 1,0, 1);
    }
    public void stopSound(int key){
        int soundId=map.get(key);
        pool.stop(soundId);
    }
    public void pauseSound(int key){
        int soundId=map.get(key);
        pool.pause(soundId);
    }
    public void resumeSound(int key){
        //만일 무음 모드면 여기서 메소드 끝내기
        if(isMute)return;

        pool.resume(map.get(key));
    }

    //자원 해제 하는 메소드
    public void release(){
        pool.release();
    }
}

 

- 정수 키 값만 전달해주면 Map으로 재생할 사운드를 알아서 관리해준다.

 

- SoundPool 객체는 긴 소리는 로딩이 안된다.

- 짧은 효과음 재생 정도만 가능하다. 긴 사운드는 MediaPlayer를 사용해야 한다.

 

- 키값을 등록하고 나서 정수 키 값을 addSound에 넘겨주면 play할 때 사용된다.

 

- 정수 키값을 사용해서 재생할 수 있다. 정지, 일시정지 메소드도 같다.

- resumeSound : 다시 플레이하는 메소드

 


 

GameActivity

package com.example.step09gameview;

import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;

public class GameActivity extends AppCompatActivity {
    //사운드 매니저 객체
    SoundManager sManager;
    //사운드의 종류별로 상수 정의하기
    public static final int SOUND_LAZER=1;
    public static final int SOUND_SHOOT=2;
    public static final int SOUND_BIRDDIE=3;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //GameView 객체를 생성해서
        GameView view=new GameView(this);
        //MainActivity 의 화면을 GameView로 모두 채운다.
        setContentView(view);
        //SoundManager 객체를 생성해서
        sManager=new SoundManager(this);
        //GameView의 setter 메소드를 이용해서 참조값을 전달해 준다.
        view.setSoundManager(sManager);
    }

    @Override
    protected void onStart() {
        super.onStart();
        //효과음 미리 로딩하기
        sManager.addSound(SOUND_LAZER, R.raw.laser1);
        sManager.addSound(SOUND_SHOOT, R.raw.shoot1);
        sManager.addSound(SOUND_BIRDDIE, R.raw.birddie);
    }

    @Override
    protected void onStop() {
        super.onStop();
        //자원 해제
        sManager.release();
    }

    //옵션 메뉴를 만드는 메소드
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        //메뉴 전개자 객체를 얻어와서
        MenuInflater inflater=getMenuInflater();
        //res/menu/menu_option.xml 문서를 전개해서 메뉴로 구성한다.
        inflater.inflate(R.menu.menu_option, menu);
        return true;
    }
    //옵션 메뉴를 선택했을 때 호출되는 메소드
    @Override
    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
        switch (item.getItemId()){
            case R.id.off: //off를 눌렀을 때 해야할 동작
                sManager.setMute(true);
                break;
            case R.id.on: //on을 눌렀을 때 해야할 동작
                sManager.setMute(false);
                break;
        }
        return super.onOptionsItemSelected(item);
    }
}

 

- activity 에서 사운드를 재생할 준비하기!

 

- 사운드 종류를 static final 상수로 관리

- 숫자보다 가독성이 좋고, 사용이 편리하기 때문에!

 

- onCreate 메소드에서 sManager 사용

- GameView에서 필요하므로 넣어줄 것이다.

 

- 필드를 만들어주고, 생성자 밑에 필드명을 적어주면 메소드가 자동완성된다.

- 안드로이드 코딩이 복잡해서, 안드로이드를 잘 공부해두면 java를 많이 익힐 수 있다.

 

- setSoundManager 로 이 뷰에 참조값을 넣어준다.

- 이런 사운드도 Activity에서 생명주기 관리를 해야한다.

 

- onStart(), onStop() 메소드 오버라이드! 

- 시작될때 재생할준비를 해주고, 정지되면 관리 해제해주기.

 

 

- 안드로이드의 생명주기를 기억하기 !

- 이 단계 사이에서 효과음 준비 / 자원 해제 작업을 해준다.

 

- onDestroy() 를 쓰지 않는 이유는? 이것이 호출이 되지 않을 때도 있어서이다.

- 어떤 어플을 오래 사용하지 않으면 운영체제가 그냥 자연스럽게 죽여버리기도 한다.

 그럼 onDestroy가 호출되지 않을 수도 있다.

 

 

 

GameView

package com.example.step09gameview;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class GameView extends View {
    //필드
    Bitmap backImg; //배경 이미지
    int width, height; //화면의 폭과 높이(GameView 가 차지하고 있는 화면의 폭과 높이)

    //드래곤의 이미지를 저장할 배열
    Bitmap[] dragonImgs = new Bitmap[4];
    //드래곤 이미지 인덱스
    int dragonIndex=0;
    //유닛(드래곤,적기)의 크기를 저장할 필드
    int unitSize;
    //드래곤의 좌표를 저장할 필드(가운데 기준)
    int dragonX, dragonY;
    //배경이미지의 y좌표
    int back1Y, back2Y;
    //카운트를 셀 필드
    int count;


    //Missile 객체를 저장할 리스트
    List<Missile> missList=new ArrayList<>();
    //미사일의 크기
    int missSize;
    //미사일 이미지를 담을 배열
    Bitmap[] missImgs=new Bitmap[3];
    //미사일의 속도
    int speedMissile;
    //적기 이미지를 저장할 배열
    Bitmap[][] enemyImgs=new Bitmap[2][2];
    //Enemy 객체를 저장할 List
    List<Enemy> enemyList=new ArrayList<>();
    //적기의 x 좌표를 저장할 배열
    int[] enemyX=new int[5];
    //랜덤한 숫자를 얻어낼 Random 객체
    Random ran=new Random();
    //적기가 만들어진 이후 count를 셀 필드
    int postCount;
    //점수 필드
    int point;

    //효과음을 재생해주는 객체!
    SoundManager soundManager;

    public GameView(Context context) {
        super(context);
    }

    public GameView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    //SoundManager 객체를 전달받아서 필드에 저장하는 메소드
    public void setSoundManager(SoundManager soundManager) {
        this.soundManager = soundManager;
    }

    //초기화 메소드
    public void init(){
        //원본 배경 이미지 읽어들이기
        Bitmap backImg= BitmapFactory.decodeResource(getResources(), R.drawable.backbg);
        //배경이미지를 view 의 크기에 맞게 조절해서 필드에 저장
        this.backImg=Bitmap.createScaledBitmap(backImg, width, height, false);
        //드래곤 이미지를 로딩해서 사이즈를 조절하고 배열에 저장한다.
        Bitmap dragonImg1=
                BitmapFactory.decodeResource(getResources(), R.drawable.unit1);
        Bitmap dragonImg2=
                BitmapFactory.decodeResource(getResources(), R.drawable.unit2);
        Bitmap dragonImg3=
                BitmapFactory.decodeResource(getResources(), R.drawable.unit3);
        dragonImg1=Bitmap
                .createScaledBitmap(dragonImg1, unitSize, unitSize, false);
        dragonImg2=Bitmap
                .createScaledBitmap(dragonImg2, unitSize, unitSize, false);
        dragonImg3=Bitmap
                .createScaledBitmap(dragonImg3, unitSize, unitSize, false);
        dragonImgs[0]=dragonImg1;
        dragonImgs[1]=dragonImg2;
        dragonImgs[2]=dragonImg3;
        dragonImgs[3]=dragonImg2;

        //미사일 이미지 로딩
        Bitmap missImg1=BitmapFactory.decodeResource(getResources(),
                R.drawable.mi1);
        Bitmap missImg2=BitmapFactory.decodeResource(getResources(),
                R.drawable.mi2);
        Bitmap missImg3=BitmapFactory.decodeResource(getResources(),
                R.drawable.mi3);
        //미사일 이미지 크기 조절
        missImg1=Bitmap.createScaledBitmap(missImg1,
                missSize, missSize, false);
        missImg2=Bitmap.createScaledBitmap(missImg2,
                missSize, missSize, false);
        missImg3=Bitmap.createScaledBitmap(missImg3,
                missSize, missSize, false);
        //미사일 이미지를 배열에 넣어두기
        missImgs[0]=missImg1;
        missImgs[1]=missImg2;
        missImgs[2]=missImg3;

        //적기 이미지 로딩
        Bitmap enemyImg1=BitmapFactory
                .decodeResource(getResources(), R.drawable.silver1);
        Bitmap enemyImg2=BitmapFactory
                .decodeResource(getResources(), R.drawable.silver2);
        Bitmap enemyImg3=BitmapFactory
                .decodeResource(getResources(), R.drawable.gold1);
        Bitmap enemyImg4=BitmapFactory
                .decodeResource(getResources(), R.drawable.gold2);

        //적기 이미지 사이즈 조절
        enemyImg1=Bitmap.createScaledBitmap(enemyImg1,
                unitSize, unitSize, false);
        enemyImg2=Bitmap.createScaledBitmap(enemyImg2,
                unitSize, unitSize, false);
        enemyImg3=Bitmap.createScaledBitmap(enemyImg3,
                unitSize, unitSize, false);
        enemyImg4=Bitmap.createScaledBitmap(enemyImg4,
                unitSize, unitSize, false);
        //적기 이미지 배열에 저장
        enemyImgs[0][0]=enemyImg1; //0행 0열 silver1
        enemyImgs[0][1]=enemyImg2; //0행 1열 silver2
        enemyImgs[1][0]=enemyImg3; //1행 0열 gold1
        enemyImgs[1][1]=enemyImg4; //1행 1열 gold2
        //적기의 x좌표를 구해서 배열에 저장한다.
        for(int i=0; i<5; i++){
            enemyX[i]= i *unitSize+ unitSize/2;
        }

        //handler 객체에 메세지를 보내서 화면이 주기적으로 갱신되는 구조로 바꾼다.
        handler.sendEmptyMessageDelayed(0,20);
    }

    //View가 활성화될 때 최초 한번 호출되고 View의 사이즈가 바뀌면 다시 호출된다.
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        //view가 차지하고 있는 폭과 높이가 px 단위로 w, h에 전달된다.
        width=w;
        height=h;
        //unitSize는 화면 폭의 1/5로 설정
        unitSize=w/5;
        //드래곤의 초기 좌표 부여
        dragonX=w/2;
        dragonY=height-unitSize/2;

        //배경이미지의 초기 좌표
        back1Y=0;
        back2Y=-height;

        //미사일의 크기는 드래곤의 크기의 1/4
        missSize=unitSize/4;

        //미사일의 속도는 화면의 높이/50
        speedMissile=h/50;

        //초기화 메소드 호출
        init();
    }

    @Override
    protected void onDraw(Canvas canvas) {

        // (0,0) 좌표에 배경 이미지 2개 그리기
        canvas.drawBitmap(backImg, 0, back1Y, null);
        canvas.drawBitmap(backImg, 0, back2Y, null);

        //미사일 그리기
        for(Missile tmp:missList){
            canvas.drawBitmap(missImgs[0], tmp.x-missSize/2, tmp.y-missSize/2, null);
        }

        //적기 그리기
        for(Enemy tmp:enemyList){

            if(tmp.isFall){ //추락 상태인 적기
                //canvas의 정상 상태(변화를 가하지 않은 상태)를 저장
                canvas.save();
                //적기의 위치로 평행이동
                canvas.translate(tmp.x, tmp.y);
                canvas.rotate(tmp.angle);
                //좀더 줄어든 크기의 Bitmap 이미지를 얻어내서
                Bitmap scaled=Bitmap.createScaledBitmap(enemyImgs[tmp.type][tmp.imageIndex],
                        tmp.size, tmp.size, false);
                //적기를 원점에 그린다.
                canvas.drawBitmap(scaled, 50-unitSize/2, -unitSize/2, null);
                //저장했던 정상 상태도 되돌린다.
                canvas.restore();
            }else{ //정상 상태의 적기
                canvas.drawBitmap(enemyImgs[tmp.type][tmp.imageIndex], tmp.x-unitSize/2, tmp.y-unitSize/2, null);
            }
        }

        //글자를 출력하기 위한 paint 객체
        Paint textP=new Paint();
        textP.setColor(Color.YELLOW);
        textP.setTextSize(50);
        //점수 출력하기. drawText( 출력할 문자열, 좌하단의 x, 좌하단의 y, Paint 객체 )
        canvas.drawText("Point : "+point, 10, 60, textP);

        //드래곤 그리기
        canvas.drawBitmap(dragonImgs[dragonIndex], dragonX-unitSize/2, dragonY-unitSize/2, null);

        //----- 배경이미지 관련 처리-----
        back1Y += 5;
        back2Y += 5;

        //만일 배경 1의 좌표가 아래로 벗어나면
        if(back1Y >= height){
            //배경1을 상단으로 다시 보낸다.
            back1Y=-height;
            //배경2와 오차가 생기지 않게 하기 위해 복원하기
            back2Y=0;
        }
        //만일 배경2의 좌표가 아래로 벗어나면
        if(back2Y>= height){
            //배경2을 상단으로 다시 보낸다.
            back2Y=-height;
            //배경1과 오차가 생기지 않게 하기 위해 복원하기
            back1Y=0;
        }

        count++;

        //----- 드래곤 애니메이션 관련 처리 -----
        if(count%10==0){
            dragonIndex++;
            if(dragonIndex==4){ //만일 존재하지 않는 인덱스라면
                dragonIndex=0; //다시 처음으로 되돌리기
            }
        }

        missileService(); //미사일 관련 처리 메소드 호출
        enemyService(); //적기 관련 처리 메소드 호출
        checkStrike(); //적기와 미사일의 충돌 검사 메소드 호출
    }

    //적기와 미사일의 충돌 검사하기
    public void checkStrike(){
        for(int i=0;i<missList.size();i++){
            //i번쨰 미사일 객체
            Missile m=missList.get(i);
            for(int j=0; j<enemyList.size(); j++){
                //j번째 적기 객체
                Enemy e=enemyList.get(j);
                //i번째 미사일이 j번째 적기의 4각형 영역 안에 있는지 여부
                boolean isStrike= m.x > e.x - unitSize/2 &&
                                  m.x < e.x + unitSize/2 &&
                                  m.y > e.y - unitSize/2 &&
                                  m.y < e.y + unitSize/2;
                if(isStrike && !e.isFall){//현재 추락중인 적기는 무시하기
                    //효과음 재생
                    soundManager.playSound(GameActivity.SOUND_SHOOT);

                    //적기 에너지를 줄이고
                    e.energy -= 50;
                    //미사일을 없앤다.
                    m.isDead=true;
                    //만일 적기의 에너지가 0 이하이면 적기가 제거되도록 한다.
                    if(e.energy <= 0){
                        //e.isDead=true; //적기는 완전히 추락한 이후에 제거되게 한다.
                        e.isFall=true; //바로 제거되는 대신 적기가 추락 상태가 되도록 한다.
                        //점수 올리기
                        point += e.type == 0 ? 100 : 200;
                    }
                }
            }
        }
    }

    //적기 관련 처리
    public void enemyService(){

        if(count%10 == 0){
            //반복문 돌면서
            for(Enemy tmp:enemyList){
                //모든 적기의 이미지 인덱스를 i 증가시킨다.
                tmp.imageIndex++;
                if(tmp.imageIndex==2){ //만일 존재하지 않는 인덱스라면
                    //다시 처음으로 돌리기
                    tmp.imageIndex=0;
                }
            }
        }

        postCount++;
        int ranNum=ran.nextInt(20);
        if(ranNum==10 && postCount > 13){
            postCount=0;
            //임의의 시점에 적기가 5개 만들어지도록 해 보세요.
            for(int i=0;i<5;i++){
                Enemy tmp=new Enemy();
                tmp.x=enemyX[i]; //x좌표는 배열에 미리 준비된 x좌표
                tmp.y=unitSize/2; //임시 y좌표
                tmp.type=ran.nextInt(2); //적기의 타입은 0 또는 1로 랜덤하게 부여
                tmp.energy= tmp.type == 0 ? 50 : 100;
                tmp.size=unitSize; //적기의 초기 크기
                //만든 적기를 적기 목록에 담기
                enemyList.add(tmp);
            }

        }

        //적기 움직이기
        for(Enemy tmp:enemyList){
            if(tmp.isFall) {
                //크기를 줄이고
                tmp.size -= 1;
                //회전값을 증가 시킨다.
                tmp.angle += 10;
                //만일 크기가 0보다 작아진다면
                if (tmp.size <= 0) {
                    //배열에서 제거될 수 있도록 표시한다.
                    tmp.isDead = true;
                }
            }

           //적기의 y좌표를 증가시키고
           tmp.y += speedMissile/2;
           //아래쪽으로 화면을 벗어났다면
           if(tmp.y > height+unitSize/2){
               //배열에서 제거될 수 있도록 표시한다.
               tmp.isDead=true;
           }
       }
       //적기 체크해서 배열에서 삭제할 적기는 제거하기
       for(int i=enemyList.size()-1;i>=0;i--){
           Enemy tmp=enemyList.get(i);
            if(tmp.isDead){
                enemyList.remove(i);
            }
       }
    }

    //미사일 관련 처리
    public void missileService(){
        //미사일 만들기
        if(count%5==0){
            //미사일 발사음 재생
            soundManager.playSound(GameActivity.SOUND_LAZER);
            missList.add(new Missile(dragonX, dragonY));
        }

        //미사일 움직이기
        for(Missile tmp:missList){
            tmp.y -= speedMissile;
            //만일 위쪽으로 화면을 벗어났다면
            if(tmp.y < -missSize/2){
                tmp.isDead=true; //배열에서 제거될 수 있도록 표시해둔다.
            }
        }
        //미사일 객체를 모두 체크해서 배열에서 제거할 객체는 제거하기(단 반복문을 역순으로 돌아야 한다)
        for(int i=missList.size()-1; i>=0; i--){
            //i번째 미사일 객체를 얻어와서
            Missile tmp=missList.get(i);
            //만일 제거할 미사일 객체라면
            if(tmp.isDead){
                //List에서 i번째 아이템을 제거한다.
                missList.remove(i);
            }
        }
    }

    //view에 터치 이벤트가 발생하면 호출되는 메소드
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        dragonX=(int)event.getX();
        return true;
    }

    Handler handler=new Handler(){
        @Override
        public void handleMessage(@NonNull Message msg) {
            //화면 갱신하기
            invalidate();
            //handler객체에 빈 메세지를 20/1000 초 이후에 다시 보내기
            handler.sendEmptyMessageDelayed(0, 20);
        }
    };
}

 

- 적절한 위치에 효과음 넣어주기.

1) 부딪치는 효과음의 재생 타이밍 : 미사일과 적기 충돌시!

 

- 이 if문 블럭 안에서 효과음이 재생되어야 한다.

 

soundManager.playSound(GameActivity.SOUND_SHOOT);

- 이 코드를 안에 넣어준다! 이렇게 상수로 설정해두면 코딩시에도 편리하다.

- 이런식으로 상수를 정의하는 것은 의미없는 숫자에 이름을 부여하는 효과를 가진다.

- 또는 복잡한 문자열을 오타 없이 쉽게 불러다 쓸 수 있게 한다.

 

 

2) 미사일 소리 : 미사일 발사시

- 이렇게 미리 준비된 효과음을 필요할 때 불러와서 재생할 수 있다.

 


 

- 옵션으로 Sound off/on 기능 추가 하기

 

- GameActivity 에서 Switch로 off, on 으로 구분해놓은 블럭 안에 코드를 넣어주면 된다.

 

- SoundManager 에 필드 추가하기

- isMute 라는 필드를 만들고 초기값을 False 로 설정

- 이 뮤트값을 제어할 수 있는 setMute 메소드 사용. (필드를 만들어두면 setter는 그냥 만들어진다)

 

 

- playSound, resumeSound 를 호출할 때 옵션이 off 상태이면 return 해버린다.

- 무음모드일 경우 playSound, resumeSound 가 실행되지않도록!

 

- SoundManager의 onOptionsItemSelected 메소드에서

  on/off일 때의 블럭에 하고자하는 기능을 넣어주면 된다.(mute값 변화시키기)

 

 

- 이런 형태로 완성되었다.

 


 

* 이외에 나중에 추가해야 할 부분!

- 현재 드래곤이 무적상태인데, 적기와 부딪치면 죽는 기능

 → 드래곤 <-> 적기 충돌검사 (드래곤은 하나라서 쉽다)

 

- 드래곤이 떨어지는 효과 등...