예제 소스
문랜딩 커스텀 환경: 2D 물리와 16가지 분사 행동 · 파일 6개
render.py
"""Pygame 기반 달 착륙선 렌더러.
학습 중에는 굳이 호출하지 않고, play.py 또는 train.py 의 평가 단계에서
GUI 로 환경 상태를 시각화하기 위해 사용한다.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
import pygame
if TYPE_CHECKING:
from lander_env import MoonLanderEnv
# ---------------- 색상 ----------------
BG_TOP = (8, 12, 30)
BG_BOTTOM = (24, 28, 60)
GROUND = (60, 60, 80)
GROUND_OUTLINE = (140, 140, 170)
TARGET = (60, 200, 80)
TARGET_FLAG = (240, 220, 100)
LANDER_BODY = (220, 220, 230)
LANDER_OUTLINE = (60, 60, 80)
LANDER_LEG = (180, 180, 190)
FLAME_INNER = (255, 220, 120)
FLAME_OUTER = (255, 120, 40)
HUD_TEXT = (230, 230, 240)
HUD_BG = (0, 0, 0, 140)
class LanderRenderer:
"""달 착륙선 환경을 화면에 그린다."""
def __init__(
self,
env: "MoonLanderEnv",
screen_w: int = 800,
screen_h: int = 800,
caption: str = "Moon Lander DQN",
) -> None:
pygame.init()
pygame.display.set_caption(caption)
self.env = env
self.screen_w = screen_w
self.screen_h = screen_h
self.screen = pygame.display.set_mode((screen_w, screen_h))
self.clock = pygame.time.Clock()
try:
self.font = pygame.font.SysFont("Menlo,Consolas,monospace", 16)
self.font_big = pygame.font.SysFont("Menlo,Consolas,monospace", 22, bold=True)
except Exception:
self.font = pygame.font.Font(None, 18)
self.font_big = pygame.font.Font(None, 24)
# 월드 -> 스크린 스케일
self.scale = min(screen_w / env.WORLD_W, screen_h / env.WORLD_H)
# 별 좌표 (배경)
import random
rng = random.Random(42)
self._stars = [
(rng.randint(0, screen_w), rng.randint(0, int(screen_h * 0.7)),
rng.choice([1, 1, 1, 2]))
for _ in range(120)
]
# ---------------- 좌표 변환 ----------------
def w2s(self, wx: float, wy: float) -> tuple[int, int]:
"""월드 좌표 -> 화면 픽셀 (y 축 뒤집힘)."""
sx = int(wx * self.scale)
sy = int(self.screen_h - wy * self.scale)
return sx, sy
# ---------------- 메인 ----------------
def draw(self, episode_info: dict | None = None) -> None:
env = self.env
# 배경 그라디언트
self._draw_background()
# 별
for sx, sy, r in self._stars:
pygame.draw.circle(self.screen, (220, 220, 240), (sx, sy), r)
# 지면
ground_y = self.w2s(0, 0)[1]
pygame.draw.rect(
self.screen, GROUND,
(0, ground_y, self.screen_w, self.screen_h - ground_y),
)
pygame.draw.line(
self.screen, GROUND_OUTLINE,
(0, ground_y), (self.screen_w, ground_y), 2,
)
# 목표 영역
tx_left = env.TARGET_X - env.TARGET_HALF_W
tx_right = env.TARGET_X + env.TARGET_HALF_W
sx_left, _ = self.w2s(tx_left, 0)
sx_right, _ = self.w2s(tx_right, 0)
pygame.draw.rect(
self.screen, TARGET,
(sx_left, ground_y - 4, sx_right - sx_left, 4),
)
# 양쪽 깃발
for fx in (tx_left, tx_right):
sx, sy = self.w2s(fx, 0)
pygame.draw.line(self.screen, TARGET_FLAG, (sx, sy), (sx, sy - 30), 2)
pygame.draw.polygon(
self.screen, TARGET_FLAG,
[(sx, sy - 30), (sx + 12, sy - 25), (sx, sy - 20)],
)
# 분사기 화염 (착륙선 본체보다 먼저 그려서 본체에 가려지게)
self._draw_flames()
# 착륙선 본체
self._draw_lander()
# HUD
self._draw_hud(episode_info)
pygame.display.flip()
def _draw_background(self) -> None:
h = self.screen_h
for y in range(0, h, 2):
t = y / h
r = int(BG_TOP[0] * (1 - t) + BG_BOTTOM[0] * t)
g = int(BG_TOP[1] * (1 - t) + BG_BOTTOM[1] * t)
b = int(BG_TOP[2] * (1 - t) + BG_BOTTOM[2] * t)
pygame.draw.line(self.screen, (r, g, b), (0, y), (self.screen_w, y), 2)
def _draw_lander(self) -> None:
env = self.env
corners_world = env.corners_world()
corners_screen = [self.w2s(*c) for c in corners_world]
# 본체
pygame.draw.polygon(self.screen, LANDER_BODY, corners_screen)
pygame.draw.polygon(self.screen, LANDER_OUTLINE, corners_screen, 2)
# 다리 (본체 하단 두 모서리에서 살짝 바깥/아래로)
s = env.state
cos_t = math.cos(s.theta)
sin_t = math.sin(s.theta)
hw = env.LANDER_W / 2
hh = env.LANDER_H / 2
leg_offsets_body = [
((-hw, -hh), (-hw - 1.0, -hh - 1.5)),
((hw, -hh), (hw + 1.0, -hh - 1.5)),
]
for (bx1, by1), (bx2, by2) in leg_offsets_body:
wx1 = s.x + cos_t * bx1 - sin_t * by1
wy1 = s.y + sin_t * bx1 + cos_t * by1
wx2 = s.x + cos_t * bx2 - sin_t * by2
wy2 = s.y + sin_t * bx2 + cos_t * by2
pygame.draw.line(self.screen, LANDER_LEG,
self.w2s(wx1, wy1), self.w2s(wx2, wy2), 3)
# 중심 마커
cx, cy = self.w2s(s.x, s.y)
pygame.draw.circle(self.screen, LANDER_OUTLINE, (cx, cy), 2)
def _draw_flames(self) -> None:
env = self.env
s = env.state
cos_t = math.cos(s.theta)
sin_t = math.sin(s.theta)
# 분사기마다 화염을 추력 반대방향으로 그림
for i, on in enumerate(env.last_thrusters):
if not on:
continue
px, py, dx, dy, _, _ = env.THRUSTERS[i]
# 분사기 노즐 위치 (월드)
nx = s.x + cos_t * px - sin_t * py
ny = s.y + sin_t * px + cos_t * py
# 화염 방향 (월드, 추력 반대)
fbx = -dx
fby = -dy
fwx = cos_t * fbx - sin_t * fby
fwy = sin_t * fbx + cos_t * fby
length = 4.0 # m
# 메인 엔진은 더 크게
if i == 0:
length = 7.0
tip = (nx + fwx * length, ny + fwy * length)
# 측면 폭 (월드 단위)
perp = (-fwy, fwx)
width = 1.2 if i != 0 else 1.6
base_l = (nx + perp[0] * width, ny + perp[1] * width)
base_r = (nx - perp[0] * width, ny - perp[1] * width)
pts_outer = [self.w2s(*p) for p in (base_l, tip, base_r)]
pygame.draw.polygon(self.screen, FLAME_OUTER, pts_outer)
# 안쪽 더 작은 화염
tip_in = (nx + fwx * length * 0.6, ny + fwy * length * 0.6)
base_l_in = (nx + perp[0] * width * 0.5, ny + perp[1] * width * 0.5)
base_r_in = (nx - perp[0] * width * 0.5, ny - perp[1] * width * 0.5)
pts_inner = [self.w2s(*p) for p in (base_l_in, tip_in, base_r_in)]
pygame.draw.polygon(self.screen, FLAME_INNER, pts_inner)
def _draw_hud(self, episode_info: dict | None) -> None:
s = self.env.state
lines = [
f"x={s.x:6.1f} y={s.y:6.1f}",
f"vx={s.vx:6.2f} vy={s.vy:6.2f}",
f"theta={math.degrees(s.theta):6.1f}deg omega={s.omega:5.2f}",
f"fuel={s.fuel:7.1f} step={self.env.steps}",
]
if episode_info:
for k, v in episode_info.items():
if isinstance(v, float):
lines.append(f"{k}={v:.2f}")
else:
lines.append(f"{k}={v}")
# 반투명 배경
pad = 6
line_h = self.font.get_linesize()
box_w = 280
box_h = pad * 2 + line_h * len(lines)
surf = pygame.Surface((box_w, box_h), pygame.SRCALPHA)
surf.fill(HUD_BG)
self.screen.blit(surf, (10, 10))
for i, line in enumerate(lines):
txt = self.font.render(line, True, HUD_TEXT)
self.screen.blit(txt, (10 + pad, 10 + pad + i * line_h))
# 분사기 상태 표시 (오른쪽 상단)
names = ["MAIN", "TOP ", "LEFT", "RGHT"]
for i, (nm, on) in enumerate(zip(names, self.env.last_thrusters)):
color = (255, 200, 80) if on else (90, 90, 110)
txt = self.font.render(f"{nm}:{'ON ' if on else 'off'}", True, color)
self.screen.blit(txt, (self.screen_w - 110, 12 + i * line_h))
# ---------------- 사용자 이벤트 ----------------
def pump_events(self) -> bool:
"""반환 False 면 사용자가 창을 닫음."""
for event in pygame.event.get():
if event.type == pygame.QUIT:
return False
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return False
return True
def tick(self, fps: int = 30) -> None:
self.clock.tick(fps)
def close(self) -> None:
pygame.quit()
def show_terminal_banner(screen: pygame.Surface, font: pygame.font.Font, text: str, color: tuple[int, int, int]) -> None:
"""에피소드 종료 시 화면 중앙에 결과 배너를 그린다 (호출자에서 flip)."""
surf = font.render(text, True, color)
rect = surf.get_rect(center=(screen.get_width() // 2, screen.get_height() // 2))
bg = pygame.Surface((rect.width + 20, rect.height + 20), pygame.SRCALPHA)
bg.fill((0, 0, 0, 180))
screen.blit(bg, (rect.left - 10, rect.top - 10))
screen.blit(surf, rect)