adsense



[JAVA게임] Asteroid Belt - 확대축소회전의 응용

확대축소회전을 응용한 게임 만들기





감기몸살로 뻗어있다가 겨우 좀 회복됐는데.. 일하려니 텐션이 안 돌아오고 엉뚱하게 이쪽으로 텐션이 튀었습니다.
간만에 새 자바 슈팅게임 하나 만들었으니 소스 공개하고 간단히 해설해 봅니다.


이번 게임은, 아타리의 전설의 명작 ASTEROIDS의 클론 게임입니다.

빙글빙글 돌면서 총을 쏘는 게임이기 때문에 회전을 응용하기에는 딱 좋은 소재라고 생각합니다.

소스 프로젝트(이클립스)용 다운로드 링크 : 여기서 받아주세요. (유료)

이클립스에서 불러오는 방법은, File->New->General->Existing Projects into Workspace입니다.


게임 조작방법

게임 시작 - 스페이스키
좌우 회전 - 커서키 좌 우
총알 발사 - 스페이스키
가속 - 커서키 위
일시정지 - ESC



일단 클래스 구조부터.

public class AsteroidBelt extends JFrame
메인 클래스입니다.

class GameScreen
Canvas와 Runnable을 가진, 게임 본편이 실제로 수행되는 부분입니다.

class Asteroid
적 캐릭터에 해당하는, 소행성(운석) 정보 클래스입니다.

class Bullet
플레이어가 발사하는 총알 클래스입니다.

class Effect
화면에 폭발 등 이펙트 그래픽 관련 정보 클래스입니다.

이번엔 각 서브 클래스는 그리기나 처리에 관한 내용을 담지 않고, GameScreen 클래스에서 일괄 처리합니다.
확장을 생각하면 좋은 방법은.. 절대 아닌데 귀차니즘이 발동해서..

확축회전 관련 내용 때문에 이미지랑 Rectangle이 연동하기때문도 있고.. 그렇습니다.

class FPoint
class FRectangle

기존의 Point나 Rectangle은 int를 사용하기 때문에 좌표 계산에 삼각함수를 이용하면 문제가 생깁니다.

예를 들어 (100,100) 에서 (205,300)으로 10프레임에 걸쳐 이동한다고 하면, 1프레임 당 이동값이 세로축 변화는 20인데, 가로축 변화는 0.5입니다.
int로 좌표를 관리하면 100에 0.5를 아무리 더해도 계속 100이라 최종적으로 이동 결과는 200,300이 되죠.

그래서 요시카 스크램블 같은 경우 좌표에 100을 곱해주는 방법을 사용했는데..
연산이 많이 들어가서 소스를 알아보기 복잡하고 좌표나 값을 예측하기 어려워집니다.

그때문에 좌표계를 float로 계산할 수 있도록 따로 만들어준 것이 이 두개의 F~ 클래스입니다.
Rectangle2D 같은 것도 있긴 한데, 닭잡소칼이라 그냥 간단히 만들어 씁니다.



프레임워크

그림으로 해설합니다.
기본 구조 자체는 요시카스크램블에 가깝지만 조금 더 간단해졌습니다.




분기 구조

각 함수를 부르고 분기하는 큰 얼개는 아래와 같습니다.
이외의 함수들은 게임 제작에 편의를 위한 함수들, 내지는 보조적인 처리를 위한 것입니다.

switch는 그중에 하나가 선택되는 분기, flow는 각 함수가 순서대로 실행되는 batch구조입니다.

※ initSound는 이번에는 사용하지 않습니다.


게임 상태의 전환

게임 상태는 크게, 게임 화면의 상태(mode), 플레이어의 상태(myStatus) 두 가지로 나뉩니다.

enum Mode가 게임 화면의 상태,
enum Status가 플레이어의 상태로 각각 선언되어 있습니다.

Mode의 경우,
TITLE - 게임 타이틀 화면
INTRO - 게임 시작 준비
PLAY - 게임 중
GAMEOVER - 게임 끝

Status의 경우
IMMOTARL - 무적 상태 (조작, 공격은 가능하지만 운석과 충돌하지 않는다)
ONPLAY - 플레이 상태 (조작, 공격이 가능하고 운석과 충돌하면 죽는다)
DEAD - 플레이어기 격추 상태
OUTPLAY - 플레이어기가 다시 등장하기 위한 준비 상태
LEVELCLEAR - 화면의 운석을 모두 제거하고 클리어가 된 상태

상태에 따른 분기와 분기 조건을 플로우차트 비슷하게 그리면 아래와 같습니다.
분기 조건에서 NO일 경우가 없는 것은, NO인 경우에는 그대로 루프를 진행하기만 하고 상태 변화는 이루어지지 않는 것입니다.


Mode나 Status를 전환하는 방법은, 해당 정보를 가진 int 변수를 하나 갖고, (mode, myStatus) 조건이 성립하면 값을 바꿔주기만 합니다.

그러면 루프에서 mode에 따라 필요한 쪽으로 switch하게 됩니다.

시간 지연은, System.currentMillisecond를 사용하는 방법도 있고, 여기서는 카운트 변수를 설정했습니다.

pCnt - 프로세스 루프가 1회 돌 때마다 1씩 증가
dCnt - 그리기 루프가 1회 돌 때마다 1씩 증가
myCnt - 플레이어 관련 호출 (processMyShip)이 1회 불릴 때마다 1씩 증가 : Mode.PLAY인 상태에서 루프 1회마다 호출됩니다.



회전의 응용

요시카 스크램블은 회전 기능이 없기에 여러 가지 각도로 발사되는 총알을 구현하기 위해 총알이 모두 둥근 모양이었습니다.
(고전게임같으면 대개 8~16방향 정도로 미리 그려놓고 씁니다)

이번엔 향하는 방향, 날아가는 방향 등에 연동해서 기울어집니다.

이미지를 기울이기 때문에 발생하는 문제점(?)이 있습니다.

1. 여러 장의 이미지를 겹쳐서 표현하는 오브젝트일 경우, 각 이미지의 위치 관계
무슨 뜻인지는 아래 그림을 참조하시면 되겠습니다.

2. 길죽한 모양의 이미지가 회전할 때의 충돌 판정
충돌 판정을 중심점으로부터의 거리로 하느냐, 사각형 충돌로 하느냐를 정해야 하는데,
요시카 스크램블의 경우는 대부분의 오브젝트가 둥글둥글하므로 중심점 판정을 해도 썩 어색하진 않습니다.
그런데, 이번엔 그렇게 안되기 때문에.. 어떻게 해결했는가는 그림으로 설명합니다.



소스 중의
static final boolean isDebug = false;
를 true로 바꿔주면, 이렇게 충돌판정으로 쓰이는 영역이 사각형으로 보입니다.


확대 사용시의 주의



기본 구조와 유의할 점은 이정도. 나머지는 소스의 주석을 참고해 주세요.

소스에 대한 지적/질문은 항상 환영합니다. 구매하신 경우 소스 내용을 응용해 쓰시는 것도 자유롭게 하셔도 됩니다.
이미지도 모두 직접 그린거라 저작권 문제 낫씽.

배경음악/효과음은.. 사운드 함수는 있습니다만, 영 불안해서 뺐습니다.. 제 컴에서는 미칠듯이 렉이 걸려서요.



덧글

  • ProfJang 2014/11/16 20:27 # 답글

    자바로 게임이라니... 으허헝.. 대단하시네요 T.T
  • 펭귄대왕 2014/11/16 21:43 #

    주력은 c/c++인데 아무 준비 없이 간단히 만들어보기엔 자바가 약간 편해서요.. 한계는 크지만
  • 하얀삼치 2014/11/17 00:20 # 답글

    헛, 좋은 자료 감사합니다.
  • 펭귄대왕 2014/11/17 14:14 #

    감사합니다 ^_^
  • 2014/11/20 15:39 # 답글 비공개

    비공개 덧글입니다.
  • 2014/11/20 15:42 # 비공개

    비공개 답글입니다.
  • 2014/11/20 16:14 # 비공개

    비공개 답글입니다.
  • 2014/11/20 17:02 # 비공개

    비공개 답글입니다.
  • 2014/11/20 17:13 # 비공개

    비공개 답글입니다.
  • 2014/11/20 17:32 # 비공개

    비공개 답글입니다.
  • 2014/11/20 17:49 # 답글 비공개

    비공개 덧글입니다.
  • 2014/11/20 17:53 # 비공개

    비공개 답글입니다.
  • 2014/11/20 18:02 # 비공개

    비공개 답글입니다.
  • 2014/11/21 11:03 # 답글 비공개

    비공개 덧글입니다.
  • 2014/11/21 11:35 # 비공개

    비공개 답글입니다.
  • 2014/11/21 14:00 # 비공개

    비공개 답글입니다.
  • 2016/05/05 03:09 # 삭제 답글 비공개

    비공개 덧글입니다.
  • 펭귄대왕 2016/05/05 03:29 #

    구매해주셔서 감사합니다.
    소스가 사무실에 있어서 지금 설명드리는 건 소스하고 완전히 일치하지는 않습니다. 이 부분은 소스 확인해서 다시 상세히 적도록 하고 개요만 우선 말씀드리겠습니다.

    우선 전체 프로그램이 스레드 구조로 순환하고 있는건 이해하셨다는걸 전제하고요,
    순환 과정에서 특정 정수 변수값을 지속적으로 증가시켜줍니다. cnt ++; 같은 식으로요.

    그리고
    http://icegeo.egloos.com/300628
    회전 이미지를 얻는 함수가 있어서, 회전 각도를 지정하면 그만큼 회전된 BufferedImage 객체를 얻을 수 있고 이걸 화면에 draw 해 줍니다.

    앞에서 cnt를 게속 증가시키고 있다고 했는데,
    여기서 cnt%360 같은 식으로 계산해주면, 0~359의 값을 순서대로 반복해서 얻을 수 있습니다.

    회전 이미지를 얻기 위한 함수를 부를 때, 회전 각도로 이 값을 넣어주면 1프레임에 1도씩 회전한 이미지를 얻게 되니, 이것을 스레드에서 그려주면 그림이 계속 회전하는 것처럼 보이게 됩니다.
    만약에 (cnt%180)*2 같은 식으로 하면 0, 2, 4, 8, .... 358의 값을 얻어서 두 배 빠르게 돌게 할 수 있고,
    (cnt/2)%360 같은 식이라면 0, 0, 1, 1, 2, 2, .... 359, 359 하여 절반 속도로 돌릴 수도 있습니다.

    로고 연출에는 이외에도 축소상태에서 점점 커지는 연출, 회전하는 각도를 원하는 각도에서 시작해 원하는 각도에서 멈추도록 수치를 예상해서 적용하는 것 등이 되어 있으니 이 부분의 상세한 내용은 소스를 확인해서 다시 보충하도록 하겠습니다.
  • 펭귄대왕 2016/05/05 14:19 #

    실제 타이틀을 그려주는 부분의 소스를 보면
    if(dCnt>=150){

    drawBImage(gc2, logoImg, frame.gGameWidth/2.0f, frame.gGameHeight/2.0f, this);
    if(dCnt%60<50)
    drawBImage(gc2, startImg, frame.gGameWidth/2.0f, frame.gGameHeight-112, this);
    }else if(dCnt<100){

    BufferedImage temp = getScaledImage(logoImg, logoImg.getWidth()*(100-dCnt)/50, logoImg.getHeight()*(100-dCnt)/50);
    drawBImage(gc2, getRotateImage(temp, (dCnt%72)*5), frame.gGameWidth/2.0f, (frame.gGameHeight -200 + dCnt*2)/2.0f, this);
    }else if(dCnt<150){

    BufferedImage temp = getScaledImage(logoImg, logoImg.getWidth()*(dCnt-50)/100, logoImg.getHeight()*(dCnt-50)/100);
    drawBImage(gc2, temp , frame.gGameWidth/2.0f, frame.gGameHeight/2.0f, this);
    }
    이렇게 되어 있는데요,

    dCnt라는 변수가 있고, 이 변수는 0에서 시작, void dblpaint()에서 계속 1씩 증가합니다.
    dCnt는 대체로 mode를 변경할 때 마다 0으로 초기화 해 줍니다.

    일단 처음 타이틀 화면에 들어오면 루프에서 수행되는 부분은 위에서 dCnt<100에 해당하는 부분입니다.

    이때 타이틀 로고는, 크게 확대된 상태에서 회전하면서 점점 작아지게 됩니다.


    BufferedImage temp에 로고 이미지 logoImg의 확대/축소된 이미지를 얻어옵니다.

    확대축소된 이미지를 얻어오는 함수인 getScaledImage의 파라미터를 보면, 원소스인 logoImg를 전달하고,
    얻고자 하는 가로 세로 크기를 지정합니다.

    가로 크기의 경우 logoImg의 가로(getWidth) * (100-dCnt) / 50 으로 해 주고 있는데요,
    로고 이미지의 가로 길이는 770입니다.
    dCnt는 최초에 0이니까 770 * (100-0) / 50 = 1540

    이것을 dCnt가 100이 되기 전까지 반복하니까, dCnt가 99라고 하면
    770 * (100-99) / 50 = 15 (정수)
    그러니까 최초는 원래 크기의 2배 크기에서 시작해서 dCnt가 99가 되는 시점에서는 오히려 원래 크기의 1/5로 줄어듭니다.


    세로 크기 지정도 마찬가지입니다.


    이렇게 얻은 이미지를 drawBImage로 그려주는데, 역시 그냥 그리지는 않고 getRotateImage로 회전시킨 이미지를 얻어서 그려줍니다.

    이때 지정되는 회전 각도는 (dCnt%72)*5)인데

    dCnt가 0일 때는 0,
    dCnt가 1일 때는 5,
    dCnt가 2일 때는 10
    .
    .
    dCnt가 99일 때는 135

    0도에서 135도까지 회전합니다.


    dCnt가 100 이상이 되면 dCnt<150 부분이 수행됩니다.
    여기서는 타이틀을 확대만 해서 원래 크기까지 키워 보여주고 회전은 하지 않습니다.

    getWidth() * (dCnt-50) / 100 이고
    dCnt는 100에서 149까지 변화하므로 가로 크기는 385~762 까지 변합니다.

    그리고 dCnt가 150을 넘어가면, 로고 이미지는 추가적인 연출 없이 그냥 보여주고 SPACE to START 문구를 일정 간격마다 깜박이면서 보이게 하는 상태를 계속 유지합니다.

    확대 축소는 getScaledImage를,
    회전은 getRotateImage를 자세히 보면 됩니다.
  • rla4512 2016/05/05 17:08 # 삭제 답글

    정말 많이 도움되고있습니다 감사드려요 ㅠㅠ
  • rla4512 2016/05/08 03:04 # 삭제 답글

    안녕하세요 저번에 회전에 관해서 질문 정말 감사드립니다 근데... 그 소행성이 깨지는 로직에 대해서는 아직 약간 이해가 부족한거같아서 질문드려요 ㅠㅠ......
    어떤 방식으로 뽀개지는지 그리고 그 해당 이미지를 어떻게 가지고오는지에 대해서 궁금합니다.
  • 펭귄대왕 2016/05/08 09:52 #

    우선 이미지는 모두 initImage()에서 미리 읽어두고 있습니다.

    이미지를 읽어들이는 구체적인 동작은 public BufferedImage makeBufferedImage(String furl) 함수에서 담당하고 있는데,
    (1)Toolkit객체를 구하고, 이것으로 getImage를 해서 png 파일을 Image객체로 읽어오고,
    (2)여기에 MediaTracker를 설정해서, 읽어들이는게 완전히 끝날 때 까지 기다리게 합니다.
    (3)마지막으로 Image 객체를 BufferedImage객체로 변환해서 리턴해 줍니다.

    (1)이 부분은 리소스 파일을 읽어오는 방법으로서 정해져 있는 것이고,
    (2)는, 드물게 이미지를 출력하기 전에 완전히 읽혀지지 않아 그림이 뜨지 않는 것을 방지하기 위한 것입니다.
    데스크탑에서는 거의 발생하지 않지만 애플릿으로 만들어 웹에서 실행될 때는 종종 볼 수 있습니다.
    (3)에서 BufferedImage를 쓰는 이유는 확대/축소/회전 등을 하기 위해서입니다.


    소행성의 파괴는 processBullets 에서 확인할 수 있습니다.
    (1) if(getCollisionAb(temp.rect, aTemp.rect)) 으로 소행성의 사각형과 총알의 사각형이 겹쳤는가 = 충돌했는가 확인해서
    (2) aTemp.hp-=temp.pow; 충돌했다면 소행성의 HP를 총알의 공격력만큼 깎고
    (3) if(aTemp.hp <= 0) 소행성의 HP가 0 이하로 떨어지면 파괴 처리를 합니다.

    소행성 객체(Asteroid)의 ingNum은 가장 큰 것부터 0, 그 다음이 1, 2, 다음이 3, 4, 마지막이 5, 6 순서로 주어집니다.

    int kind = (aTemp.imgNum%2)==0?aTemp.imgNum+1:aTemp.imgNum+2;
    이 부분을 보면

    최초의 소행성을 파괴하면 해당 imgNum은 0이므로 kind는 aTemp.imgNum+1 = 1이 됩니다.

    if(kind<7)에서, 아직 kind가 7보다 작으므로 이 안쪽을 수행해서
    newCreate1 = (createAsteroids(kind, (이하생략)
    newCreate2 = (createAsteroids(kind+1, (이하생략)
    이와같이 두 개의 소행성을 추가로 생성하게 됩니다.

    가장 작은 소행성의 imgNum은 5와 6이므로, 이것을 파괴하면 kind는 7이 되고, 가장 작은 것을 파괴한 후에는 더 이상 생성하지 않게 됩니다.

    원래의 소행성은 삭제되고, 새롭게 두 개의 소행성을 생성하는 것으로 둘로 쪼개지는 효과를 내는 것입니다.
    이때 새로 생성하는 소행성의 속도나 이동방향을 기존 소행성의 것을 기준으로 약간의 난수를 줘서 원래의 소행성에서 튕겨나온 것 처럼 연출합니다.

    if(aTemp.hp <= 0) 안쪽에서는 이외에 점수의 처리, 폭파연기의 발생(실제 그리는 것은 다른 부분에서) 등을 처리합니다.
댓글 입력 영역


Books

Geek라이프

게임 매니악스 슈팅 게임 알고리즘
마츠우라 켄이치로,츠카사 유키 공저/손정도 역/박민근,Pope Kim 감수

게임 매니악스 퍼즐 게임 알고리즘
마츠우라 켄이치로,츠카사 유키 공저/김병국 역

핵심강좌! Cocos2d-x
이재환 저

될 수 있어! SE 13
나츠미 코지 저/Ixy 그림/김경훈 역
예스24 | 애드온2