이번 실습에서는 Java Swing을 이용해 배경 이미지 위에 캐릭터를 배치하고, 방향키로 움직이면서 맵의 벽을 통과하지 못하도록 충돌 판정을 구현했다.
처음부터 충돌 기능까지 한 번에 구현한 것은 아니고,
배경 출력
→ 캐릭터 출력
→ 키보드 이동
→ 벽 좌표 지정
→ 이동 전 좌표 검사
→ Rectangle 충돌 판정
→ HitBox 디버깅
→ 여러 벽 적용
→ 배열을 이용한 리팩토링
순서로 기능을 하나씩 추가했다.
먼저 JFrame을 상속받은 MyFrame 클래스를 만들었다.
배경 이미지는 ImageIcon으로 불러오고 JLabel에 넣었다.
ImageIcon bgIcon = new ImageIcon("이미지 경로");
JLabel backgroundMap = new JLabel(bgIcon);
backgroundMap.setSize(1000, 600);
처음에는 이미지를 불러왔는데도 화면이 하얗게 나오는 문제가 있었다.
원인은 JLabel 객체만 생성했을 뿐 실제 JFrame에 추가하지 않았기 때문이었다.
add(backgroundMap);
여기서 Swing에서는 객체를 생성하는 것과 화면에 배치하는 것이 별개의 과정이라는 것을 알 수 있었다.
캐릭터 역시 ImageIcon과 JLabel을 사용했다.
ImageIcon playerR = new ImageIcon("playerR.png");
ImageIcon playerL = new ImageIcon("playerL.png");
JLabel playerCharacter = new JLabel(playerR);
playerCharacter.setSize(100, 100);
캐릭터는 배경 이미지 위에서 움직여야 하므로 JFrame에 직접 추가하는 대신 backgroundMap에 추가했다.
backgroundMap.add(playerCharacter);
시작 위치는 별도의 메서드로 분리했다.
private void startLocate() {
x = 200;
y = 272;
playerCharacter.setLocation(x, y);
}
이렇게 해두면 게임 시작 시뿐 아니라 ESC를 눌러 캐릭터를 원래 위치로 되돌리는 데도 같은 코드를 재사용할 수 있다.
키 입력은 KeyListener를 이용했다.
처음에는 방향키를 누르면 x, y를 바로 변경했다.
if (e.getKeyCode() == KeyEvent.VK_LEFT) {
x -= MOVE_STEP;
playerCharacter.setLocation(x, y);
}
여기까지는 단순한 캐릭터 이동에는 문제가 없었다.
하지만 벽을 구현하려고 하니 문제가 생겼다.
좌표를 먼저 변경해버리면 벽인지 아닌지 검사하기 전에 캐릭터가 이동한다.
그래서 실제 좌표를 변경하기 전에 다음에 이동할 좌표를 계산하도록 구조를 변경했다.
int nextX = x;
int nextY = y;
그리고 키에 따라 nextX, nextY만 먼저 변경했다.
if (e.getKeyCode() == KeyEvent.VK_UP) {
nextY -= MOVE_STEP;
} else if (e.getKeyCode() == KeyEvent.VK_LEFT) {
nextX -= MOVE_STEP;
playerCharacter.setIcon(playerL);
} else if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
nextX += MOVE_STEP;
playerCharacter.setIcon(playerR);
} else if (e.getKeyCode() == KeyEvent.VK_DOWN) {
nextY += MOVE_STEP;
}
결국 이동 과정이 다음과 같이 바뀌었다.
키 입력
↓
nextX / nextY 계산
↓
이동 가능한 위치인지 검사
↓
가능 → 실제 x/y 변경
불가능 → 기존 위치 유지
이 구조가 이후 충돌 판정의 핵심이 되었다.
맵에는 캐릭터가 통과할 수 없는 구조물이 존재한다.
처음에는 벽마다 직접 좌표를 지정했다.
Rectangle stool1 = new Rectangle(218, 227, 580, 38);
Rectangle stool2 = new Rectangle(218, 346, 580, 38);
Rectangle stool3 = new Rectangle(218, 466, 580, 38);
Rectangle의 생성자는 다음과 같다.
new Rectangle(x, y, width, height);
즉,
new Rectangle(218, 227, 580, 38);
은 (218, 227) 위치에서 시작하는 가로 580px, 세로 38px 크기의 사각형 영역이다.
벽은 Rectangle이 되었지만 캐릭터는 JLabel이었다.
따라서 캐릭터가 다음 위치로 이동했을 때의 충돌 영역을 임시 Rectangle로 만들었다.
Rectangle nextPlayer = new Rectangle(x + 30, y + 30, 40, 40);
그리고 Rectangle이 제공하는 intersects()를 사용했다.
nextPlayer.intersects(stool1)
두 Rectangle이 겹치면 true, 겹치지 않으면 false를 반환한다.
따라서 이동 가능 여부를 담당하는 메서드를 만들었다.
private boolean canMove(int x, int y) {
Rectangle nextPlayer = new Rectangle(x + 30, y + 30, 40, 40);
if (nextPlayer.intersects(stool1)) {
return false;
}
return true;
}
이후 실제 이동은 다음처럼 변경했다.
if (canMove(nextX, nextY)) {
x = nextX;
y = nextY;
playerCharacter.setLocation(x, y);
}
이렇게 하면서 이동 처리와 충돌 판정이 서로 분리되었다.
충돌 판정을 처음 적용했을 때 캐릭터가 아예 움직이지 않는 문제가 발생했다.
처음 캐릭터 충돌 영역을 다음처럼 만들었기 때문이다.
new Rectangle(x, y, 100, 100);
playerCharacter의 JLabel 크기가 100 × 100이어서 그대로 사용했지만, 실제 PNG 속 캐릭터는 JLabel 전체를 차지하지 않았다.
즉 화면에서는 벽과 떨어져 있어 보이지만 Java가 판단하는 충돌 영역은 이런 상태였다.
┌────────────────┐
│ │
│ 캐릭터 │ ← 실제 그림
│ │
└────────────────┘
↑
100 × 100 전체가 HitBox
그래서 캐릭터가 시작부터 벽과 충돌한 것으로 판정되고 있었다.
충돌 영역을 실제 캐릭터에 맞게 줄였다.
new Rectangle(x + 30, y + 30, 40, 40);
여기서 +30은 100×100 JLabel 안에 있는 실제 캐릭터 위치를 맞추기 위한 offset이다.
숫자만 보면서 HitBox를 조절하는 것은 생각보다 어려웠다.
그래서 아예 Java가 판단하고 있는 충돌 영역을 빨간 사각형으로 화면에 표시하기로 했다.
캐릭터용 디버그 JLabel을 만들었다.
debugPlayer = new JLabel();
debugPlayer.setSize(40, 40);
debugPlayer.setBorder(BorderFactory.createLineBorder(Color.RED, 2));
캐릭터가 이동할 때 이 디버그 박스도 같이 움직였다.
debugPlayer.setLocation(x + 30, y + 30);
벽도 같은 방법으로 표시했다.
JLabel debugWall = new JLabel();
debugWall.setBounds(wall);
debugWall.setBorder(BorderFactory.createLineBorder(Color.RED, 2));
backgroundMap.add(debugWall);
이 작업 덕분에 화면에서
실제 이미지
+
캐릭터 HitBox
+
벽 HitBox
를 동시에 확인할 수 있었고, 잘못된 좌표를 훨씬 쉽게 수정할 수 있었다.
이 과정에서 화면에 보이는 이미지와 프로그램이 판단하는 충돌 영역은 별개라는 것도 알 수 있었다.
처음에는 벽을 각각 변수로 관리했다.
private Rectangle stool1;
private Rectangle stool2;
private Rectangle stool3;
private Rectangle leftWall1;
private Rectangle leftWall2;
private Rectangle leftWall3;
private Rectangle rightWall1;
private Rectangle rightWall2;
private Rectangle rightWall3;
충돌 검사도 마찬가지였다.
if (nextPlayer.intersects(stool1)) {
return false;
} else if (nextPlayer.intersects(stool2)) {
return false;
} else if (nextPlayer.intersects(stool3)) {
return false;
} else if (nextPlayer.intersects(leftWall1)) {
return false;
}
// ...
벽이 늘어날수록 코드가 계속 길어졌다.
디버그 HitBox 역시 벽 하나마다 JLabel을 생성해야 해서 같은 코드가 반복되었다.
여기서 벽은 전부 Rectangle이라는 동일한 종류의 데이터라는 점을 이용해 배열로 묶었다.
기존의 수많은 Rectangle 필드를 없애고:
private Rectangle[] walls;
하나만 만들었다.
그리고 모든 벽을 배열에 저장했다.
walls = new Rectangle[] {
// 중앙 발판
new Rectangle(218, 227, 580, 38),
new Rectangle(218, 346, 580, 38),
new Rectangle(218, 466, 580, 38),
// 왼쪽
new Rectangle(60, 227, 75, 38),
new Rectangle(60, 346, 75, 38),
new Rectangle(60, 466, 75, 38),
// 오른쪽
new Rectangle(880, 227, 60, 38),
new Rectangle(880, 346, 60, 38),
new Rectangle(880, 466, 60, 38),
// 외벽
new Rectangle(62, 10, 878, 20),
new Rectangle(62, 582, 878, 18),
new Rectangle(45, 30, 18, 570),
new Rectangle(939, 30, 18, 570)
};
그러자 canMove()가 크게 줄었다.
private boolean canMove(int x, int y) {
Rectangle nextPlayer = new Rectangle(x + 30, y + 30, 40, 40);
for (Rectangle wall : walls) {
if (nextPlayer.intersects(wall)) {
return false;
}
}
return true;
}
벽이 3개든 10개든 100개든 canMove() 코드는 더 이상 바뀌지 않는다.
새로운 벽이 필요하면:
new Rectangle(x, y, width, height)
를 배열에 추가하면 된다.
이 과정에서 배열을 사용하는 이유를 실제로 체감할 수 있었다.
배열로 변경한 직후 충돌 판정이 제대로 작동하지 않는 문제가 발생했다.
처음 작성했던 코드는 다음과 같았다.
for (Rectangle wall : walls) {
if (nextPlayer.intersects(wall)) {
return false;
}
return true;
}
처음 보기에는 문제가 없어 보였지만 return true가 for문 내부에 있었다.
따라서 실제 실행은:
첫 번째 벽 확인
↓
충돌하면 false
충돌하지 않으면
↓
바로 true 반환
↓
메서드 종료
나머지 벽은 검사하지 않음
이었다.
return은 반복문을 종료하는 것이 아니라 메서드 자체를 종료한다.
따라서 모든 벽을 검사한 뒤에 true를 반환하도록 수정했다.
for (Rectangle wall : walls) {
if (nextPlayer.intersects(wall)) {
return false;
}
}
return true;
이 구조는 의미상으로도 이해하기 쉽다.
벽을 하나씩 검사한다.
하나라도 충돌하면 이동할 수 없으므로 즉시
false.모든 벽을 검사했는데도 살아남았다면
true.
벽 충돌 검사뿐 아니라 디버그 HitBox 생성도 같은 배열을 사용할 수 있었다.
기존에는 벽마다:
JLabel debugWall1 = new JLabel();
JLabel debugWall2 = new JLabel();
JLabel debugWall3 = new JLabel();
처럼 만들었지만, 배열 적용 후에는 다음 코드 하나로 끝났다.
private void debugWallHitbox() {
for (Rectangle wall : walls) {
JLabel debugWall = new JLabel();
debugWall.setBounds(wall);
debugWall.setBorder(BorderFactory.createLineBorder(Color.RED, 2));
backgroundMap.add(debugWall);
}
}
여기서 특히 재미있었던 점은 하나의 walls 데이터가 여러 기능에서 재사용된다는 것이었다.
Rectangle[] walls
│
┌─────────┴─────────┐
↓ ↓
canMove() debugWallHitbox()
│ │
충돌 판정 화면 표시
벽 정보를 한 군데에서 관리하므로 벽을 추가하거나 수정할 때 다른 코드까지 일일이 변경할 필요가 없어졌다.
