이전 절에 예고된대로 먼저, 게임의 시작처리와 공의 움직임 처리에 대해서 아보도록 하자. 게임의 시작은 기존 처럼 누구든 먼저 스페이스 바를 누르면서 시작할 수 있다. 다만, 네트워크 게임의 특성상 그룹방 안에 게임 시작 가능 총 인원이 다 입장되어 있는지의 추가 확인이 필요하고(37라인) 확인된 이후에 게임시작이 가능한다. 게임의 시작하게 되면 원격에 떨어져 있는 상대도 곧바로 게임을 시작할 수 있도록 상대에게 game_start 메시지를 보낼 필요가 있다(40라인).
class Ball(Rect):
...
def collide_wall(self, sound_only=False):
# 위쪽 또는 아래쪽 벽
if self.top < 0 or self.bottom > HEIGHT:
if not sound_only:
self.vy = -self.vy # 속도의 y축 방향을 반대로하기
sounds.wall.play()
# 왼쪽 벽
if self.left < 0:
if not sound_only:
self.reset()
sounds.die.play()
return 'b2'
# 오른쪽 벽
if self.right > WIDTH:
if not sound_only:
self.reset()
sounds.die.play()
return 'b1'
return ''
...
def update():
global num_peers
...
# 게임시작 조건
if score.is_game_over():
# 게임 시작은 누구나 먼저 시작가능
if num_peers == MAX_PEERS and keyboard.space:
score.reset()
ball.reset()
net.send_msg(peer_id, 'game_start', True)
else: # 공
if is_host:
ball.move()
net.send_msg('Player2', 'ball_pos', {'center': ball.center, 'vel': (ball.vx, ball.vy)})
who_win = ball.collide_wall()
if who_win: # 점수
score.add(who_win)
net.send_msg('Player2', 'score', who_win)
ball.reset()
else: # Plyer2
ball.move()
ball.collide_wall(sound_only=True)
...
딕셔너리, 사전 (Dictionary)
먼저, 딕셔너리는 그 이름 그대로, 사전이란 뜻인데, 사전은 특정 단어와 그 단어의 뜻을 쌍(pair)으로 모아놓은 집합과 같다 할 수 있다. 파이썬의 딕셔너리라는 데이터 타입도 그러하다. 리스트가 단순한 모든 종류의 데이터들의 모음(객체)이라면, 딕셔너리는 그 각각의 데이터(값(Value)라고 호칭)에 그 데이터를 지칭하는 특정이름(키(Key)라고 호칭)까지 함께 쌍으로 묶은 데이터들의 모음(객체)이라고 할 수 있다. 리스트에서는 리스트 안에 값을 참조하기 위해 인덱스를 사용했다면, 딕셔너리에서는 인덱스 없이 대신 자신을 지칭하는 사전에 설정한 그 이름(key)으로 값을 참조할 수 있게 된다. 다음의 사용 문법을 보면 더 쉽게 이해할 수 있다.
딕셔너리 이름 = {키: 값 의 쌍의 형태로 구성된 여러 값. 단, 키와 값의 쌍들 사이는 콤마(,)로 구분}
딕셔너리 예시코드
person = {'name': 'Bob', 'occupation': 'Engineer'}
print(person['name']) # 출력: name (키를 사용하여 값 접근)
person['age'] = 25 # 새로운 키-값 쌍 추가
person['occupation'] = 'Software Engineer' # 기존 키의 값 변경
del person['age'] # 특정 키-값 쌍 삭제
print(person) # 출력: {'name': 'Bob', 'occupation': 'Software Engineer'}
이제 공이 벽에 부딪히는 상황에 대한 처리로 기존에 collide_wall 멤버함수에 일부 변형이 적용되었는데, 공이 화면 좌우벽에 부딪힌 상황 즉, 승패가 난 상황에 대해 네트워크를 통한 원격 점수 업데이트(48라인)가 필요하기 때문에 그 부분에 대한 처리가 적용되었다. collide_wall 를 자세히 살펴보면, 기존에는 승점 발생시 객체 자신이 점수를 직접 업데이트 했었는데 이제는 승점을 획득한 편이 누구인지에 대한 값(b1 또는 b2)만을 리턴할 뿐(16, 23라인), 승점의 업데이트는 리턴값을 받은 쪽에서 업데이트 하도록 위임한다(45~47라인). 그리고, collide_wall 멤버함수에도 sound_only 라는 파마미터가 추가되었는데, 이전 반사판에서의 경우와 동일하게 호스트가 아닌 일반유저의 경우 공의 반사에 대해서 단지 효과음 처리만 하려는 의도이다.
다음으로는 살펴보는 것을 미뤄둔 수신 메시지의 처리에 대한 부분을 살펴보도록 하자. 수신 메시지의 통합적인 처리를 위해 handle_recv_messages 함수에 안에서 처리한다.
def handle_recv_messages():
# 전체 메시지 수신
net.process_recv()
# 특정 메시지 구분
game_start = net.get_msg(peer_id, 'game_start')
bar_pos = net.get_msg(peer_id, 'bar_pos', clear=False)
ball_pos = net.get_msg("Player1", 'ball_pos', clear=False)
who_win = net.get_msg("Player1", 'score')
# 게임 시작
if game_start:
score.reset()
ball.reset()
# 공
if ball_pos:
ball.pos_update(ball_pos['center'], ball_pos['vel'])
# 바
if bar_pos:
if is_host:
bar2.pos_update(bar_pos)
else: # Plyer2
bar1.pos_update(bar_pos)
# 점수
if who_win:
score.add(who_win)
def update():
global num_peers
# 네트워크 수신 메시지 처리
handle_recv_messages()
...
우리가 송신 메시지 처리에서 상대에게 보내려는 메시지들을 버퍼라는 임시공간에 모았다가 주기적으로 한꺼번에 보내는 식으로 적용했던 것을 기억할 것이다. 수신하는 쪽에 처리에서도 이와 유사하게 메시지 수신 버퍼라는게 존재하여 네트워크를 통해서 계속 유입되는 메시지들을 수신 버퍼라는 공간에 계속 모아두었다가 주기적으로 한꺼번에 처리하는 방식을 사용하며, 따라서, 그러한 식의 처리를 위해서 NetNode 객체 안에 process_recv 멤버함수를 주기적으로 호출할 필요가 있다(3라인).
먼저 우리는 상대방에서 수신된 메시지들을 각각을 구분하는게 필요하고, 이후 그 구분된 메시지에 맞게 처리를 하면된다. 메시지의 구분은 NetNode 객체의 get_msg 멤버함수를 통해 할 수 있다. 이 함수는 3가지 파라미터 값을 갖는다. 첫번째로 이 (정보성)메시지의 수신자의 그룹방에서의 id값으로 여기서는 peer_id를 인자로 넘겼는데, 마찬가지로 이전 장의 connect_server 함수에서 이 값이 사전 설정되어 있다. 즉, 호스트가 된 게임유저는 상대인 일반유저에게/일반유저인 게임유저는 호스트에게 메시지를 보내게 되는 것이다. 두번째 값은 메시지 수신자가 자신에게 도착하면 여러 메시지들 중에서 특정 메시지를 구분할 수 있게 하려는 목적의 메시지 식별자(일종의 id)이다. 세번째 값으로 clear라는 값이 있는데, 이 값은 필요하면 사용할 수 있고 필요 없다면 구지 값을 넘길 필요는 없다. 이 파마미터의 목적은 수신 버퍼에서 메시지를 읽어드린 후 그 메시지를 수신 버퍼에서 삭제할 것인지의 여부를 결정한다.
이제 남은 것은 각 수신 메시지의 상세처리 부분인 11~29라인은 직관적으로 이해가 가능한 내용이라 추가적인 부연설명을 하지 않아도 여러분들이 충분히 이해할 수 있을 것으로 기대한다. 이로써 pong게임을 네트워크 게임으로 만드는 모든 과정은 끝이 났다. 이처럼 네트워크 게임은 게임자체에 대한 내용과 더불어 네트워크라는 추가적인 코딩요소가 더해지기 때문에 다소 난이도가 있는 편이다. 그러나, 이제 네트워크라고 하는 부분은 우리의 일상에서 땔 수 없는 부분이고, 따라서, 모든 프로그래밍 요소에서 항상 등장하기 때문에 피할 수 있는 것이 아니고, 반드시 지식적 이해가 있어야 하는 부분으로 받아드리면 좋겠다.
이제 마지막으로 해당 게임의 전체 소스코드를 살펴보는 것으로 마무리 하겠다.
pong_net.py
import random
import math
from nethelper import NetNode
# 게임화면
TITLE = 'pong'
WIDTH = 800
HEIGHT = 600
# 반사판
BAR_H = 100
BAR_W = 15
GAP_FROM_SCR = 20
# 볼
BALL_RADIUS = 10
VELOCITY = 5
SPEED_UP = 1.08
# 점수
FINAL_SCORE = 11
# 네트워크
SERVER_IP = 'localhost' # 릴레이서버 IP주소
is_host = False # 그룹의 host역할 여부
peer_id = '' # 통신 상대방의 id
MAX_PEERS = 2 # 최대 게임가능 인원
num_peers = 0 # 현재 게임참여 인원
my_bar = ''
class Score():
'''
화면에 점수와 승패의 UI 표현
'''
def __init__(self, final_score):
self._final_score = final_score
self._b1_score = -1
self._b2_score = -1
def _gameover_draw(self):
winner = ''
if self._b1_score == self._final_score:
winner = 'Player1'
elif self._b2_score == self._final_score:
winner = 'Player2'
if winner:
screen.draw.text(winner + ' Win!!', (WIDTH/3, HEIGHT/2 - 50), color='blue', fontsize=70)
screen.draw.text('Press Space to play again', (WIDTH/4, HEIGHT/2 + 50), color='skyblue', fontsize=50)
def is_game_over(self):
if (self._b1_score == self._final_score or self._b2_score == self._final_score) \
or (self._b1_score == -1 and self._b2_score == -1):
return True
else:
return False
def add(self, who):
if who == 'b1':
self._b1_score += 1
elif who == 'b2':
self._b2_score += 1
def reset(self):
self._b1_score = 0
self._b2_score = 0
def reinit(self):
self._b1_score = -1
self._b2_score = -1
def draw(self):
screen.draw.text(str(self._b1_score), (WIDTH/4, GAP_FROM_SCR), color='yellow', fontsize=60)
screen.draw.text(str(self._b2_score), ((WIDTH/4)*3, GAP_FROM_SCR), color='yellow', fontsize=60)
if self.is_game_over():
self._gameover_draw()
class Ball(Rect):
'''
공을 동작(위치, 움직임)과 충돌검사
'''
def __init__(self, start_pos, velocity, score):
super().__init__(0, 0, BALL_RADIUS * math.sqrt(2), BALL_RADIUS * math.sqrt(2))
self.center = start_pos
self._velocity = velocity
self.vx = self.vy = self._velocity
self._score = score
def pos_update(self, center, vel):
self.centerx, self.centery = center
self.vx, self.vy = vel
def reset(self):
if self._score.is_game_over():
self.vx = self.vy = 0
else:
self.vx = self.vy = self._velocity
self.vx *= random.choice([-1, 1])
# 중심선에서 좌우로 랜덤하게 던지기
self.center = (WIDTH/2 , random.randint(BALL_RADIUS*2, \
HEIGHT - BALL_RADIUS*2))
def move(self):
# vx와 vy만큼 속도로 공을 이동시키기
self.move_ip(self.vx, self.vy)
def collide_wall(self, sound_only=False):
# 위쪽 또는 아래쪽 벽
if self.top < 0 or self.bottom > HEIGHT:
if not sound_only:
self.vy = -self.vy # 속도의 y축 방향을 반대로하기
sounds.wall.play()
# 왼쪽 벽
if self.left < 0:
if not sound_only:
self.reset()
sounds.die.play()
return 'b2'
# 오른쪽 벽
if self.right > WIDTH:
if not sound_only:
self.reset()
sounds.die.play()
return 'b1'
return ''
def draw(self):
screen.draw.filled_circle(self.center, BALL_RADIUS, 'white')
class Bar(Rect):
def __init__(self, x, y, ball):
super().__init__(x, y, BAR_W, BAR_H)
self.ball = ball
def up(self):
if self.y > 0:
self.y -= 7
def down(self):
if self.y + self.height < HEIGHT:
self.y += 7
def pos_update(self, center):
self.centerx, self.centery = center
def collide_ball(self, sound_only=False):
if self.colliderect(self.ball):
if not sound_only:
# # 10픽셀 앞으로 먼저 튀어오르기
if self.ball.vx < 0: # bar1 이면
self.ball.x += 10
else: # bar2 이면
self.ball.x -= 10
self.ball.vx = -self.ball.vx * SPEED_UP # 속도의 x축 방향을 반대로하기
''' 공이 윗측 진입하면서 반사판 윗측에 부딪힐 때 또는
공이 아래측 진입하면서 반사판 아래측에 부딪힐 때는 진입방향 그대로 반사 '''
if (self.ball.vy > 0 and self.ball.centery < self.centery) or \
(self.ball.vy < 0 and self.ball.centery > self.centery):
self.ball.vy = -self.ball.vy * SPEED_UP
sounds.bar.play()
def draw(self):
screen.draw.filled_rect(self, 'white')
# 주인공 객체들 생성
score = Score(FINAL_SCORE)
ball = Ball((WIDTH/2, HEIGHT/2), VELOCITY, score)
bar1 = Bar(GAP_FROM_SCR, HEIGHT/2 - BAR_H/2, ball)
bar2 = Bar(WIDTH - BAR_W - GAP_FROM_SCR, HEIGHT/2 - BAR_H/2, ball)
bars = [bar1, bar2]
net = NetNode()
def connect_server():
global is_host, peer_id, my_bar
# 맨 처음 연결된 사람이 host (Player1) 역할
if net.connect(SERVER_IP, "Player1", "pong_game", wait=True):
is_host = True
peer_id = "Player2"
my_bar = bar1
print("Connected as host (Player 1)")
else: # 이미 호스트가 존재하면, peer (Player2) 역할
if net.connect(SERVER_IP, "Player2", "pong_game", wait=True):
is_host = False
peer_id = "Player1"
my_bar = bar2
print("Connected as peer (Player 2)")
else:
print("Failed to connect. The group is full.")
exit()
# 게임시작 전 릴레이서버와 연결설정
connect_server()
def draw():
screen.clear()
screen.draw.line((WIDTH/2, GAP_FROM_SCR), (WIDTH/2, HEIGHT - GAP_FROM_SCR), \
color='grey')
if not score.is_game_over():
ball.draw()
bar1.draw()
bar2.draw()
score.draw()
if num_peers < MAX_PEERS:
screen.draw.text('Waiting for a peer connection...', (WIDTH/6, HEIGHT/2), color='blue', fontsize=50)
def handle_recv_messages():
# 전체 메시지 수신
net.process_recv()
# 특정 메시지 구분
game_start = net.get_msg(peer_id, 'game_start')
bar_pos = net.get_msg(peer_id, 'bar_pos', clear=False)
ball_pos = net.get_msg("Player1", 'ball_pos', clear=False)
who_win = net.get_msg("Player1", 'score')
# 게임 시작
if game_start:
score.reset()
ball.reset()
# 공
if ball_pos:
ball.pos_update(ball_pos['center'], ball_pos['vel'])
# 바
if bar_pos:
if is_host:
bar2.pos_update(bar_pos)
else: # Plyer2
bar1.pos_update(bar_pos)
# 점수
if who_win:
score.add(who_win)
def update():
global num_peers
# 네트워크 수신 메시지 처리
handle_recv_messages()
# 반사판
if keyboard.a or keyboard.up:
my_bar.up()
if keyboard.z or keyboard.down:
my_bar.down()
net.send_msg(peer_id, 'bar_pos', my_bar.center)
for bar in bars:
if is_host:
bar.collide_ball()
else: # Plyer2
bar.collide_ball(sound_only=True)
# 게임시작 조건
if score.is_game_over():
# 게임 시작은 누구나 먼저 시작가능
if num_peers == MAX_PEERS and keyboard.space:
score.reset()
ball.reset()
net.send_msg(peer_id, 'game_start', True)
else: # 공
if is_host:
ball.move()
net.send_msg('Player2', 'ball_pos', {'center':ball.center, 'vel':(ball.vx, ball.vy)})
who_win = ball.collide_wall()
if who_win: # 점수
score.add(who_win)
net.send_msg('Player2', 'score', who_win)
ball.reset()
else: # Plyer2
ball.move()
ball.collide_wall(sound_only=True)
# 상대 연결 끓김
num_peers = len(net.get_peers())
if num_peers < MAX_PEERS:
if is_host:
score.reinit()
ball.reset()
else: # Plyer2
print("The host is disconnected.")
exit() # 호스트가 종료되면 방 폭파
# 메시지 전송
net.process_send()
다음으로는 41라인 이후의 볼의 움직임에 관한 것으로, 이전에 살펴본 반사판의 움직임 처리와 많이 유사하다. 공의 움직임에 처리에 대한 책임은 호스트가 갖고 있으며 공의 현재 위치정보를 ball_pos 메시지를 통해 지속적으로 상대에게 보내상대도 자신의 화면에서 공의 위치를 실시간 업데이트 할 기회를 주고 있다(44라인). 공의 위치정보는 공의 중심좌표(ball.center)와 이동속도(ball.vx, ball.vy)에 대해 (튜플)값을 {'center':ball.center, 'vel':(ball.vx, ball.vy)} 라는 데이터 형태로 제공하고 있음을 주의할 필요가 있다. 딕셔너리라고 하는 데이터 타입은 이 책에서 처음으로 등장했기 때문에, 해당 데이터 타입 등장한 목적과 사용 문법을 익힐 필요가 있다.
이런 사유가 왜 필요할까를 고민해보자. 해당 값은 네트워크라는 특성에서 오는 예외적인 필요가 있어서 존재하는데 네트워크는 그 특성상 네트워크 상에 오가는 데이터들 사이에 이 발생할 수 있음을 염두해야 한다. 예를 들어, 상대방의 실시간 위치표시 등과 같이 정보성 메시지가 늦게 도착할 경우를 생각해보자. 이 때, 내 화면에서 상대방을 멈춰있는 것으로 보이거나, 움직임에 끊김현상이 발생하여 매우 부자연스러게 보일 수 있다. 이런 경우를 대비해 clear값을 False로 설정하면, 해당 메시지 id에 해당하는 새로운 메시지가 도착하기 전까지는 기존 메시지를 삭제하지 않고 유지하는 방법을 통해, 화면 UI적인 멈춤현상을 억제하는 것이다. 여기서는 공의 움직임(ball_pos), 바의 움직임(bar_pos)의 두 개의 실시간 움직임처리에 대해서 clear=Flase를 설정했다. 추가로 clear 값에 더 자세히 알고싶을 경우, 을 참고할 수 있다.
참고로 해당 게임을 이제 로컬호스트 내에서 플레이 아는게 아닌, 실제 원격에 떨어진 유저와 게임을 하길 원할 것이다. 이 경우, 릴레이 서버 프로그램을 로컬 네트워크 (LAN) 내에 또는 전 세계 모든 원격 사용자가 접속할 수 있는 에 설치할 필요가 있을 수 있고, 또 해당 서버 프로그램이 설치되는 운영체제(OS: Operating System)에 따라 설정을 조절해 실행되고 있는 릴레이 서버 프로그램으로의 원할한 원격접속이 이뤄지도록 하는 네트워크 설정을 조절할 필요한다. 이에 대한 자세한 설명은 을 통해 상세 확인할 수 있다. 만약, 앞서 언급한 설치와 설정을 모두 완료해 릴레이 서버 프로그램이 네트워크 상에 정상 실행되고 있다면, 우리의 게임 프로그램이 이 서버에 접속해야 하는데, 이 때는 먼저 에서 언급했던 것처럼 단 한 줄의 수정이 필요한데, 릴레이 서버가 운용 중인 컴퓨터의 인터넷 상의 고유한 IP 주소를 SERVER_IP 값에 설정해 주어야 한다.
이로써 귀하는 이 책의 모든 여정을 맞췄다. 먼저, 이 여정을 마친 여러분의 수고에 진심으로 박수를 보내며, 이 책은 에 비해서는 확실히 난이도가 있었지만, 그런 다소 고통스런(?) 과정을 통과하면서 여러분은 분명히 전보다 많이 성장했음을 믿어 의심지 않는다. 다행히도 여러분의 여정은 혼자가 아니라는 것을 기억하고, 함께 할 수 있는 누군가가 있다는 것으로 힘을 낼 수 있을 것이다. 저자는 앞으로의 여러분의 여정에도 함께 동반자가 되어 게쏙 여러분을 지원하고 도울 것을 약속드리며, 다음의 또다른 책에서 여러분을 다시 만나길 고대하겠다.