게시판문랜딩 커스텀 환경: 2D 물리와 16가지 분사 행동예제 소스

예제 소스

문랜딩 커스텀 환경: 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)