Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 티스토리챌린지
- AI 기술
- 인공지능
- 오픈AI
- ChatGPT
- OpenCV
- AI
- 일론 머스크
- 이미지 생성
- tts
- 오블완
- 딥러닝
- 확산 모델
- 트랜스포머
- OpenAI
- 오픈소스
- 서보모터
- 생성형 AI
- 메타
- TRANSFORMER
- 다국어 지원
- ubuntu
- 아두이노
- PYTHON
- 시간적 일관성
- 가상환경
- 우분투
- LLM
- LORA
- Stable Diffusion
Archives
- Today
- Total
AI 탐구노트
Pygame을 이용한 Spaceship 게임 만들어보기 본문
RUST로 만들어진 벡터 그래픽 렌더링 소프트웨어로 forma라는 것이 있습니다. 구글이 3년 정도 전에 공식 릴리즈는 아니고 실험실 형태로 개발해서 공개한 것인데 최근에 벡터 이미지 생성과 관련된 기술을 찾아보다가 다시 한번 들여다 보게 되었습니다. 그런데... 정작 보고 Feel이 꽂힌 것은 그 기술이 아니라 해당 github repository에 샘플로 보여주고 있는 애니메이션이었습니다.
코드에 반영할 요구사항
다음과 같은 요구사항을 적용했습니다. 처음에는 단순했는데 하나씩 추가하다 보니 많이 복잡해졌네요.
- python, pygame을 이용할 것
- 운석과 비행선을 각각의 클래스로 구현
- 운석
- 생성 수량 설정 (기본 10개)
- 화면 바깥에서 생성해서 화면을 지나가도록 함
- 크기는 최소값, 최대값 사이의 랜덤 크기로, 색상도 달리하고, 폴리곤 형태로 뾰족하지 않은 감자모양(!)으로 생성해야함
- 비행선
- 마우스의 움직임을 따르되 비행선 자체의 방향도 마우스 움직임 방향으로 정렬
- 충돌 관련
- 비행선의 마스크 기준으로 충돌 여부 판별
- 충돌 시 충돌음 삽입
- 운석 간, 비행선과 운석 간 충돌 시 물리법칙 적용 (반발계수 개별 적용)
- 단축키
- 'q' (종료), 's' (일시정지), 'g' (게임시작), 'r'(리셋) 기능 구현
- 게임 요소
- 제한 시간 10초로 설정 내에 에너지 게이지가 0% 보다 크면 성공, 0%가 되면 실패
- 에너지 게이지는 100%에서 한번 부딫힐 때마다 10%씩 감소
사운드 리소스
- 비행선 이미지는 배경이 투명한 png 사용했습니다. (위의 애니메이션 화면을 캡처해서 DALL-E한테 만들어 달라고 함)
- 충돌음은 Pixabay에서 다운받은 1초짜리 효과음을 다시 잘라내서 사용했습니다.
코드
space_game.py
import pygame
import random
import math
import numpy as np
import sys
import time
# 초기화
pygame.init()
# 화면 설정
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Spaceship Game with Physics")
# 색상
WHITE = (255, 255, 255)
GRAY = (150, 150, 150)
BLACK = (0, 0, 0)
GREEN = (0, 255, 0)
RED = (255, 0, 0)
# FPS 설정
clock = pygame.time.Clock()
FPS = 60
# 설정 변수
count_asteroids = 10 # 화면에 표시되는 운석의 수
scale = 1.0 # 운석의 크기 배율
min_size = 10 # 운석 최소 크기
max_size = 100 # 운석 최대 크
# 물리 상수
ASTEROID_ELASTICITY = 1.0 # 운석 간 충돌 반발 계수
SHIP_ASTEROID_ELASTICITY = 0.6 # 비행선과 운석 간 충돌 반발 계수
MIN_SPEED = 2 # 최소 속도
# 회전 각도 보정 (이미지의 기본 방향에 따라 조정)
ANGLE_OFFSET = 90 # 기본적으로 위쪽을 향하도록 설정
# 게임 설정
GAME_DURATION = 10 # 게임 진행 시간 (초)
ENERGY_DECREMENT = 10 # 충돌 시 에너지 감소량 (%)
COOLDOWN_TIME = 100 # 충돌 처리 최소 시간 간격 (밀리초)
# 사운드 로드
try:
# collision_sound = pygame.mixer.Sound("collision.wav") # 충돌 시 재생할 사운드 파일
collision_sound = pygame.mixer.Sound("collision2.mp3") # 충돌 시 재생할 사운드 파일
except pygame.error:
print("collision.wav 파일을 찾을 수 없습니다. 사운드 없이 게임을 진행합니다.")
collision_sound = None # 사운드 파일이 없을 경우 None으로 설정
# 이미지 로드 및 설정
try:
spaceship_original_img = pygame.image.load("spaceship.png").convert_alpha()
spaceship_original_img = pygame.transform.scale(spaceship_original_img, (int(100 * scale), int(100 * scale))) # 크기 조정
# 색상을 흰색으로 변경하지 않습니다. 원본 이미지의 투명도를 유지합니다.
except pygame.error:
# 이미지 로드 실패 시, 기본 삼각형 모양으로 대체
spaceship_original_img = pygame.Surface((50, 50), pygame.SRCALPHA)
pygame.draw.polygon(spaceship_original_img, WHITE, [(25, 0), (0, 50), (50, 50)])
# 비행선 클래스
class Spaceship(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.original_image = spaceship_original_img
self.image = self.original_image
self.rect = self.image.get_rect(center=(WIDTH // 2, HEIGHT - 100))
self.mask = pygame.mask.from_surface(self.image)
self.speed_x = 0
self.speed_y = 0
self.mass = 100 # 비행선의 질량
self.energy = 100 # 초기 에너지 (%)
def update(self):
# 게임이 진행 중일 때만 이동 및 회전
if game_state == "running":
# 마우스 위치를 따라 움직이기
mouse_x, mouse_y = pygame.mouse.get_pos()
dx = mouse_x - self.rect.centerx
dy = mouse_y - self.rect.centery
self.speed_x, self.speed_y = dx * 0.1, dy * 0.1
self.rect.center = (self.rect.centerx + self.speed_x, self.rect.centery + self.speed_y)
# 마우스 방향을 향하도록 회전 각도 계산
angle_rad = math.atan2(mouse_y - self.rect.centery, mouse_x - self.rect.centerx)
angle_deg = math.degrees(angle_rad) + ANGLE_OFFSET # 이미지의 기본 방향에 따라 보정
self.image = pygame.transform.rotate(self.original_image, -angle_deg) # Pygame은 반시계 방향으로 회전
self.mask = pygame.mask.from_surface(self.image)
self.rect = self.image.get_rect(center=self.rect.center)
def handle_collision(self, asteroid, current_time):
global game_state, game_over_reason
# 마지막 충돌 시간과 현재 시간을 비교하여 충돌 빈도 제한
if current_time - asteroid.last_collision_time >= COOLDOWN_TIME:
# 에너지 감소
self.energy -= ENERGY_DECREMENT
asteroid.last_collision_time = current_time
if self.energy <= 0:
self.energy = 0
game_state = "game_over"
game_over_reason = "energy_depleted"
# 충돌 효과음 재생
if collision_sound:
collision_sound.play()
# 위치 조정하여 겹침 방지
dx = self.rect.centerx - asteroid.rect.centerx
dy = self.rect.centery - asteroid.rect.centery
distance = math.sqrt(dx**2 + dy**2)
min_distance = (self.rect.width // 2) + (asteroid.rect.width // 2)
if distance == 0:
# 충돌 중심이 동일한 경우 임의의 방향 설정
angle = random.uniform(0, 2 * math.pi)
else:
angle = math.atan2(dy, dx)
overlap = min_distance - distance
if overlap > 0:
move_x = math.cos(angle) * (overlap / 2)
move_y = math.sin(angle) * (overlap / 2)
self.rect.centerx += move_x
self.rect.centery += move_y
asteroid.rect.centerx -= move_x
asteroid.rect.centery -= move_y
# 운석 클래스
class Asteroid(pygame.sprite.Sprite):
def __init__(self, existing_asteroids):
super().__init__()
self.size = random.randint(int(min_size * scale), int(max_size * scale))
self.color = self.generate_bright_color()
self.image = self.generate_asteroid_shape(self.size, self.color)
self.mask = pygame.mask.from_surface(self.image)
self.rect = self.image.get_rect(center=self.generate_starting_position())
self.last_collision_time = 0 # 마지막 충돌 시간 (밀리초)
# 겹치지 않도록 위치 조정
attempts = 0
max_attempts = 100
while any(self.rect.colliderect(a.rect.inflate(-10, -10)) for a in existing_asteroids):
self.rect.center = self.generate_starting_position()
attempts += 1
if attempts > max_attempts:
break # 너무 많은 시도 후 포기
self.speed_x, self.speed_y = self.generate_initial_speed()
self.ensure_min_speed()
def generate_bright_color(self):
# 밝은 색상 생성
return (
random.randint(128, 255),
random.randint(128, 255),
random.randint(128, 255)
)
def generate_asteroid_shape(self, size, color):
"""
size: 폴리곤의 대략적인 크기 (반지름)
return: 생성된 폴리곤의 surface
"""
# 제어점의 개수 설정 (더 많은 제어점으로 수정)
num_points = random.randint(8, 12)
# 중심점 설정
center = (size, size)
# 제어점 생성 (변동폭 감소)
control_points = []
for i in range(num_points):
angle = (2 * math.pi * i / num_points)
# 반지름 변동폭을 줄임 (90~110%)
radius = size * (0.9 + random.random() * 0.2)
x = center[0] + radius * math.cos(angle)
y = center[1] + radius * math.sin(angle)
control_points.append((x, y))
# 베지어 곡선을 위한 보간점 생성
points = []
num_segments = 30 # 세그먼트 수 증가
# 3차 베지어 곡선을 위한 제어점 계산
for i in range(num_points):
p0 = control_points[i]
p1 = control_points[(i + 1) % num_points]
# 이전과 다음 점도 고려
p_prev = control_points[(i - 1) % num_points]
p_next = control_points[(i + 2) % num_points]
# 제어점 계산을 위한 벡터 생성
v_prev = (p0[0] - p_prev[0], p0[1] - p_prev[1])
v_next = (p1[0] - p_next[0], p1[1] - p_next[1])
# 제어점 위치 계산 (부드러운 전환을 위해)
ctrl1_x = p0[0] + v_prev[0] * 0.15
ctrl1_y = p0[1] + v_prev[1] * 0.15
ctrl2_x = p1[0] + v_next[0] * 0.15
ctrl2_y = p1[1] + v_next[1] * 0.15
# 3차 베지어 곡선 포인트 생성
for t in np.linspace(0, 1, num_segments):
# 3차 베지어 곡선 공식
t2 = t * t
t3 = t2 * t
mt = 1 - t
mt2 = mt * mt
mt3 = mt2 * mt
x = mt3 * p0[0] + 3 * mt2 * t * ctrl1_x + 3 * mt * t2 * ctrl2_x + t3 * p1[0]
y = mt3 * p0[1] + 3 * mt2 * t * ctrl1_y + 3 * mt * t2 * ctrl2_y + t3 * p1[1]
points.append((int(x), int(y)))
# Surface 생성
surface = pygame.Surface((size * 2, size * 2), pygame.SRCALPHA)
# 폴리곤 그리기 (안티앨리어싱 적용)
pygame.draw.polygon(surface, color, points, 0)
return surface
def generate_starting_position(self):
side = random.choice(["top", "bottom", "left", "right"])
if side == "top":
return (random.randint(0, WIDTH), random.randint(-200, -50))
elif side == "bottom":
return (random.randint(0, WIDTH), random.randint(HEIGHT + 50, HEIGHT + 200))
elif side == "left":
return (random.randint(-200, -50), random.randint(0, HEIGHT))
elif side == "right":
return (random.randint(WIDTH + 50, WIDTH + 200), random.randint(0, HEIGHT))
def generate_initial_speed(self):
center_x, center_y = WIDTH // 2, HEIGHT // 2
angle = math.atan2(center_y - self.rect.centery, center_x - self.rect.centerx)
speed = random.uniform(2, 6)
return math.cos(angle) * speed, math.sin(angle) * speed
def ensure_min_speed(self):
if abs(self.speed_x) < MIN_SPEED:
self.speed_x = MIN_SPEED if self.speed_x >= 0 else -MIN_SPEED
if abs(self.speed_y) < MIN_SPEED:
self.speed_y = MIN_SPEED if self.speed_y >= 0 else -MIN_SPEED
def update(self):
# 게임이 진행 중일 때만 이동
if game_state == "running":
self.rect.x += self.speed_x
self.rect.y += self.speed_y
if self.rect.top > HEIGHT or self.rect.left > WIDTH or self.rect.right < 0 or self.rect.bottom < 0:
self.rect.center = self.generate_starting_position()
self.speed_x, self.speed_y = self.generate_initial_speed()
self.ensure_min_speed()
def handle_collision(self, other):
dx = self.rect.centerx - other.rect.centerx
dy = self.rect.centery - other.rect.centery
distance = math.sqrt(dx**2 + dy**2)
min_distance = (self.rect.width // 2) + (other.rect.width // 2)
if distance < min_distance: # 충돌 발생
overlap = min_distance - distance
angle = math.atan2(dy, dx)
# 위치 조정
self.rect.x += math.cos(angle) * (overlap / 2)
self.rect.y += math.sin(angle) * (overlap / 2)
other.rect.x -= math.cos(angle) * (overlap / 2)
other.rect.y -= math.sin(angle) * (overlap / 2)
# 속도 계산
relative_velocity_x = self.speed_x - other.speed_x
relative_velocity_y = self.speed_y - other.speed_y
relative_normal_velocity = (relative_velocity_x * math.cos(angle) +
relative_velocity_y * math.sin(angle))
if relative_normal_velocity > 0:
return
self_mass = self.size
other_mass = other.size
total_mass = self_mass + other_mass
impulse = (2 * relative_normal_velocity) / total_mass
self.speed_x -= impulse * other_mass * math.cos(angle) * ASTEROID_ELASTICITY
self.speed_y -= impulse * other_mass * math.sin(angle) * ASTEROID_ELASTICITY
other.speed_x += impulse * self_mass * math.cos(angle) * ASTEROID_ELASTICITY
other.speed_y += impulse * self_mass * math.sin(angle) * ASTEROID_ELASTICITY
# 게임 상태 변수
game_state = "waiting" # "waiting", "running", "game_over"
start_time = None
game_over_reason = ""
# 그룹 생성
all_sprites = pygame.sprite.Group()
asteroids = pygame.sprite.Group()
existing_asteroids = []
for _ in range(count_asteroids):
asteroid = Asteroid(existing_asteroids)
all_sprites.add(asteroid)
asteroids.add(asteroid)
existing_asteroids.append(asteroid)
spaceship = Spaceship()
all_sprites.add(spaceship)
def reset_game():
global game_state, start_time, game_over_reason, all_sprites, asteroids, existing_asteroids, spaceship
# 그룹 초기화
all_sprites.empty()
asteroids.empty()
existing_asteroids = []
# 운석 재생성
for _ in range(count_asteroids):
asteroid = Asteroid(existing_asteroids)
all_sprites.add(asteroid)
asteroids.add(asteroid)
existing_asteroids.append(asteroid)
# 우주선 재생성
spaceship = Spaceship()
all_sprites.add(spaceship)
# 게임 상태 초기화
game_state = "waiting"
start_time = None
game_over_reason = ""
def display_ui():
# 폰트 설정
font = pygame.font.SysFont(None, 30)
# 남은 시간 계산
if game_state == "running":
elapsed_time = time.time() - start_time
remaining_time = max(0, GAME_DURATION - int(elapsed_time))
else:
remaining_time = GAME_DURATION
# 에너지 게이지 그리기
energy_bar_width = 200
energy_bar_height = 20
energy_ratio = spaceship.energy / 100
pygame.draw.rect(screen, GRAY, (WIDTH - energy_bar_width - 10, 10, energy_bar_width, energy_bar_height))
pygame.draw.rect(screen, GREEN, (WIDTH - energy_bar_width - 10, 10, energy_bar_width * energy_ratio, energy_bar_height))
energy_text = font.render(f"Energy: {int(spaceship.energy)}%", True, WHITE)
screen.blit(energy_text, (WIDTH - energy_bar_width - 10, 35))
# 남은 시간 표시
time_text = font.render(f"Time: {remaining_time}s", True, WHITE)
screen.blit(time_text, (WIDTH - energy_bar_width - 10, 60))
def main():
global game_state, start_time, game_over_reason
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_q:
running = False
elif event.key == pygame.K_g and game_state == "waiting":
# 게임 시작
game_state = "running"
start_time = time.time()
elif event.key == pygame.K_r and game_state == "game_over":
# 게임 재시작
reset_game()
if game_state == "running":
current_time = pygame.time.get_ticks() # 현재 시간 (밀리초)
all_sprites.update()
# 운석 간 충돌 처리
asteroids_list = list(asteroids)
for i in range(len(asteroids_list)):
for j in range(i + 1, len(asteroids_list)):
asteroid1 = asteroids_list[i]
asteroid2 = asteroids_list[j]
if pygame.sprite.collide_mask(asteroid1, asteroid2):
asteroid1.handle_collision(asteroid2)
# 우주선과 운석 간 충돌 처리
collided_asteroids = pygame.sprite.spritecollide(spaceship, asteroids, False, pygame.sprite.collide_mask)
for asteroid in collided_asteroids:
spaceship.handle_collision(asteroid, current_time)
# 게임 시간 확인
elapsed_time = time.time() - start_time
if elapsed_time >= GAME_DURATION:
if spaceship.energy > 0:
game_over_reason = "time_up_success"
else:
game_over_reason = "time_up_failure"
game_state = "game_over"
# 게임 종료 조건 확인
if spaceship.energy <= 0 and game_state != "game_over":
game_over_reason = "energy_depleted"
game_state = "game_over"
# 배경 그리기
screen.fill(BLACK)
# 스프라이트 그리기
all_sprites.draw(screen)
# UI 그리기
display_ui()
# 게임 상태에 따른 메시지 표시
if game_state == "waiting":
font = pygame.font.SysFont(None, 48)
start_text = font.render("Press 'G' to Start the Game", True, WHITE)
screen.blit(start_text, (WIDTH // 2 - start_text.get_width() // 2, HEIGHT // 2 - start_text.get_height() // 2))
elif game_state == "game_over":
font = pygame.font.SysFont(None, 48)
if game_over_reason in ["energy_depleted", "time_up_failure"]:
result_text = "Failure!"
color = RED
elif game_over_reason == "time_up_success":
result_text = "Success!"
color = GREEN
else:
result_text = "Game Over"
color = RED
result_render = font.render(result_text, True, color)
screen.blit(result_render, (WIDTH // 2 - result_render.get_width() // 2, HEIGHT // 2 - result_render.get_height() // 2 - 50))
# 상세 메시지
detail_text = ""
if game_over_reason == "energy_depleted":
detail_text = "Energy depleted!"
elif game_over_reason == "time_up_success":
detail_text = "You survived the time limit!"
elif game_over_reason == "time_up_failure":
detail_text = "You ran out of energy in time!"
else:
detail_text = "Game Over!"
detail_render = font.render(detail_text, True, WHITE)
screen.blit(detail_render, (WIDTH // 2 - detail_render.get_width() // 2, HEIGHT // 2 - detail_render.get_height() // 2))
# 재시작 안내
restart_text = font.render("Press 'R' to Restart", True, WHITE)
screen.blit(restart_text, (WIDTH // 2 - restart_text.get_width() // 2, HEIGHT // 2 - restart_text.get_height() // 2 + 50))
pygame.display.flip()
clock.tick(FPS)
pygame.quit()
sys.exit()
if __name__ == "__main__":
main()
실행 결과
실행 결과를 보면 아주 간단한 게임임을 알 수 있습니다. 열심히 다가 오는 운석을 피해야 하는거죠. 운 좋으면 운석이 많이 나오지 않고 천천히 나오지만 운 나쁘면 완전히 포위될 수도 있습니다. 여튼 짧은 시간동안 즐기기에는 꽤 쓸만하네요.
정리하며
벡터 이미지 생성 모델을 찾다가 갑자기 꽂힌 화면 때문에 뜬금없이 게임을 만들어 봤습니다. 내용은 단순하지만 구현 시 꽤나 고민해야 할 사항들이 많구나 하는 생각이 들었습니다. 그래도 나만의 요건을 제시하고 그것이 실제로 동작하는 게임으로 만들어지는 것을 보니 흠... 이 재미에 개발을 하는구나 싶었습니다. 제가 작업을 한 건 아니지만... ^^; 게다가 아이한테 시켜봤더니 좋아하더라구요. 이래저래 나름 보람을 느끼게 해 준 짧은 과제였던 것 같습니다.
'DIY 테스트' 카테고리의 다른 글
CCTV 영상에서의 대기자 수 및 대기 시간 측정 (0) | 2025.01.19 |
---|---|
MeloTTS : CPU로도 실시간 음성합성을 지원하는 경량 TTS 모델 (1) | 2025.01.18 |
OpenCV로 문서 스캐너 만들어 보기 (1) | 2025.01.10 |
직소퍼즐, 집중과 휴식이 함께 할 수 있는 게임 (0) | 2025.01.08 |
AI를 이용한 간단한 안면 식별 서비스 개발 테스트 (1) | 2025.01.07 |