범용 JS/AI 게임 제작 가이드 (v3.6)

초보자부터 전문가까지, 자신만의 AI 게임을 만들 수 있는 최종 워크플로우

전체 프로세스 개요

  1. 게임 아이디어 구체화: 만들고 싶은 게임의 상세 기획안 작성
  2. 통합 AI 웹 게임 제작: AI 학습과 플레이가 모두 가능한 JS 게임 로직 개발
  3. Python WebSocket 환경 설정: JS 게임을 제어하는 Gymnasium 호환 RL 환경 설정
  4. AI 모델 학습 및 변환: 게임 종류에 맞는 방식으로 AI 모델을 학습하고 ONNX 파일로 자동 변환
  5. 최종 AI 게임 테스트: 로컬 서버로 완성된 게임 테스트
  6. GitHub Pages 배포: 완성된 AI 웹 게임을 전 세계에 무료로 배포

단계 1: 게임 아이디어 구체화하기

'추천 주제 보기' 버튼을 눌러 영감을 얻고, 상세 기획안을 요청하는 프롬프트를 복사하세요.

You are a creative game designer. Based on the theme "[여기에 선택한 게임 주제와 간단한 설명을 입력하세요.]", please develop a detailed and engaging game plan with these four sections: Game Concept, Player Objective, Controls, Win/Loss Conditions. Write in Korean.

단계 2: 통합 AI 웹 게임 제작

파일 생성 안내

  1. 프로젝트 폴더(예: `my-ai-game`)를 만들고 코드 에디터로 엽니다.
  2. 폴더 안에 `index.html`이라는 빈 파일을 생성합니다.
  3. 아래 탭에서 원하는 작업을 선택하고 프롬프트를 복사하여 AI에게 요청한 뒤, 생성된 전체 코드를 `index.html` 파일에 붙여넣으세요.

게임 기획안 입력

1단계에서 생성된 기획안을 아래 입력란에 붙여넣으면, AI 웹 게임 생성을 위한 프롬프트가 자동으로 완성됩니다.

You are an elite game developer, a master of Matter.js, and an expert in creating robust, AI-ready web games. Your task is to write the complete code for a single, self-contained `index.html` file based on the provided game plan.

**Game Logic Plan:**

[여기에 1단계에서 생성된 게임 기획안을 붙여넣으세요.]

**CRITICAL JavaScript Implementation Requirements:**

1.  **Game Modes & State:** Must support `USER`, `AI`, `TRAINING` modes. Use `sessionStorage` to remember the current mode after a refresh, automatically re-entering `TRAINING` mode if it was active. Provide clear UI indicators for the current mode.

2.  **Reinforcement Learning Deadlock Prevention:**
    * In `USER` and `AI` modes, the Matter.js `Runner` must be enabled (`runner.enabled = true`) for smooth, real-time gameplay.
    * **Crucially, upon entering `TRAINING` mode, the automatic `Runner` MUST be disabled (`runner.enabled = false`).**
    * In `TRAINING` mode, the physics simulation must only advance a single step via a manual call to `Engine.update(engine, 1000 / 60)` from within the `stepGameForTraining` function. This gives the Python RL agent absolute control over the game loop.

3.  **WebSocket Interface & Strict RL Protocol:**
    * Implement `connectWebSocket()` to connect to `ws://localhost:8765`.
    * The interface must handle `reset` and `action` commands.
    * **Strict RL Protocol:**
        * The `resetForTraining()` function must ONLY send back the initial observation packet: `{observation: getObservation()}`.
        * The `stepGameForTraining(action)` function must send the complete data packet: `{observation, reward, done}`.

4.  **Robust Asynchronous Model Loading & State Handling:**
    * The ONNX model must be loaded asynchronously via a dedicated `loadOnnxModel` function.
    * Implement a state flag (e.g., `isModelReady = false`). This flag must be set to `true` **only after** the `ort.InferenceSession` object is created AND its internal metadata (e.g., `ortSession.inputNames`) is confirmed to be fully available.
    * The AI decision-making loop (`setInterval`) **must not be initiated** by default. It should only be started by the `setGameMode('AI')` function, and **only if `isModelReady` is already `true`**.
    * If `setGameMode('AI')` is called *before* the model is ready, `loadOnnxModel` has the final responsibility to check the current game mode upon completion and start the AI decision loop itself. This dual-check mechanism robustly handles all race conditions.
    * The UI must clearly indicate the model's status (`Loading...`, `Loaded`, `Failed`).

5.  **Decoupled AI Inference Loop:**
    * For the real-time `AI` mode, model inference (the "brain") must be decoupled from the physics loop (the "body").
    * Inference should run on a fixed, slower interval (e.g., `setInterval` at 20Hz / 50ms) to make decisions.
    * The physics engine (`Runner`) should run at maximum speed. On every physics tick, it must apply the single, most recent action decided upon by the "brain" (e.g., `latestAiAction`).

6.  **Strict Environment Consistency:**
    * The physics of the `TRAINING` environment and the `AI` inference environment must be identical.
    * The mathematical formula and all constants used to convert the model's `action` output into a physical `Force` **must be 100% identical** across `stepGameForTraining` and the `AI` mode's logic in `gameLoop`.
    * The `USER` mode may use different force constants for better playability, but this must not affect the other two modes.

7.  **Robust Physics & World Design:**
    * Use Matter.js collision categories to prevent unintended physical interactions.
    * Game resets must be stable, using a state flag (e.g., `isRoundOver`).
    * The game world should be significantly larger than the visible screen, with the camera tracking the player. Implement a "world wrap" (teleporting from one end to the other) to allow for infinite movement.

8.  **Core RL Functions & State Management:**
    * Implement `getObservation()`, `resetForTraining()`, and `stepGameForTraining(action)`.
    * **Meaningful Resets:** The `resetGame()` function must always initialize the environment in a **non-trivial, randomized state** that requires immediate action.
    * **High-Throughput Training:** In `TRAINING` mode, reaching a terminal state (`done: true`) must trigger an **immediate** reset.

// --- For 2-Player / Self-Play Games ONLY ---
/*
9.  **Dual ONNX Model Support:**
    * The game must load `model.onnx` and `opponent.onnx`. Use a cache-busting query (`?v=` + Date.now()) when loading `opponent.onnx`.
    * In `TRAINING` mode, the opponent character must be controlled by the `opponent.onnx` model.
*/
You are an expert reinforcement learning engineer. Your task is to modify the provided HTML game code to make it compatible with a Python-based training environment. You MUST inject the following features without breaking the original game's functionality.

You are an expert AI integration specialist and a master of JavaScript game engineering. Your task is to intelligently modify an existing single-file HTML game to make it a robust environment for reinforcement learning. You must analyze the provided game code and inject the necessary logic without breaking the original gameplay.

**CRITICAL JavaScript Implementation Requirements:**

1.  **Game Modes & State:** Must support `USER`, `AI`, `TRAINING` modes. Use `sessionStorage` to remember the current mode after a refresh, automatically re-entering `TRAINING` mode if it was active. Provide clear UI indicators for the current mode.

2.  **Reinforcement Learning Deadlock Prevention:**
    * In `USER` and `AI` modes, the Matter.js `Runner` must be enabled (`runner.enabled = true`) for smooth, real-time gameplay.
    * **Crucially, upon entering `TRAINING` mode, the automatic `Runner` MUST be disabled (`runner.enabled = false`).**
    * In `TRAINING` mode, the physics simulation must only advance a single step via a manual call to `Engine.update(engine, 1000 / 60)` from within the `stepGameForTraining` function. This gives the Python RL agent absolute control over the game loop.

3.  **WebSocket Interface & Strict RL Protocol:**
    * Implement `connectWebSocket()` to connect to `ws://localhost:8765`.
    * The interface must handle `reset` and `action` commands.
    * **Strict RL Protocol:**
        * The `resetForTraining()` function must ONLY send back the initial observation packet: `{observation: getObservation()}`.
        * The `stepGameForTraining(action)` function must send the complete data packet: `{observation, reward, done}`.

4.  **Robust Asynchronous Model Loading & State Handling:**
    * The ONNX model must be loaded asynchronously via a dedicated `loadOnnxModel` function.
    * Implement a state flag (e.g., `isModelReady = false`). This flag must be set to `true` **only after** the `ort.InferenceSession` object is created AND its internal metadata (e.g., `ortSession.inputNames`) is confirmed to be fully available.
    * The AI decision-making loop (`setInterval`) **must not be initiated** by default. It should only be started by the `setGameMode('AI')` function, and **only if `isModelReady` is already `true`**.
    * If `setGameMode('AI')` is called *before* the model is ready, `loadOnnxModel` has the final responsibility to check the current game mode upon completion and start the AI decision loop itself. This dual-check mechanism robustly handles all race conditions.
    * The UI must clearly indicate the model's status (`Loading...`, `Loaded`, `Failed`).

5.  **Decoupled AI Inference Loop:**
    * For the real-time `AI` mode, model inference (the "brain") must be decoupled from the physics loop (the "body").
    * Inference should run on a fixed, slower interval (e.g., `setInterval` at 20Hz / 50ms) to make decisions.
    * The physics engine (`Runner`) should run at maximum speed. On every physics tick, it must apply the single, most recent action decided upon by the "brain" (e.g., `latestAiAction`).

6.  **Strict Environment Consistency:**
    * The physics of the `TRAINING` environment and the `AI` inference environment must be identical.
    * The mathematical formula and all constants used to convert the model's `action` output into a physical `Force` **must be 100% identical** across `stepGameForTraining` and the `AI` mode's logic in `gameLoop`.
    * The `USER` mode may use different force constants for better playability, but this must not affect the other two modes.

7.  **Robust Physics & World Design:**
    * Use Matter.js collision categories to prevent unintended physical interactions.
    * Game resets must be stable, using a state flag (e.g., `isRoundOver`).
    * The game world should be significantly larger than the visible screen, with the camera tracking the player. Implement a "world wrap" (teleporting from one end to the other) to allow for infinite movement.

8.  **Core RL Functions & State Management:**
    * Implement `getObservation()`, `resetForTraining()`, and `stepGameForTraining(action)`.
    * **Meaningful Resets:** The `resetGame()` function must always initialize the environment in a **non-trivial, randomized state** that requires immediate action.
    * **High-Throughput Training:** In `TRAINING` mode, reaching a terminal state (`done: true`) must trigger an **immediate** reset.

// --- For 2-Player / Self-Play Games ONLY ---
/*
9.  **Dual ONNX Model Support:**
    * The game must load `model.onnx` and `opponent.onnx`. Use a cache-busting query (`?v=` + Date.now()) when loading `opponent.onnx`.
    * In `TRAINING` mode, the opponent character must be controlled by the `opponent.onnx` model.
*/

**Original Game Code:**

[여기에 사용자의 기존 게임 코드를 붙여넣으세요.]

단계 3: Python WebSocket 강화학습 환경 설정

파일 생성 및 라이브러리 설치

  1. 프로젝트 폴더에 `websocket_env.py` 파일을 생성하고 아래 코드를 붙여넣습니다.
  2. 터미널을 열고 `pip install gymnasium numpy websockets` 명령어로 라이브러리를 설치합니다.
import gymnasium as gym
from gymnasium import spaces
import numpy as np
import asyncio
import websockets
import json
import logging
import threading
import queue

# Websockets 라이브러리의 상세 로그 비활성화
logging.getLogger("websockets").setLevel(logging.ERROR)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')

class ServerThread(threading.Thread):
    """
    백그라운드에서 웹소켓 서버를 실행하고 메인 스레드와 큐를 통해 통신하는
    독립적인 스레드입니다.
    """
    def __init__(self):
        super().__init__()
        self.loop = asyncio.new_event_loop()
        self.command_queue = queue.Queue()
        self.observation_queue = queue.Queue()
        self.daemon = True # 메인 스레드가 종료되면 함께 종료

    # --- FIX ---
    # 최신 websockets 라이브러리(v10.0+)는 핸들러에 'path' 인자를 전달하지 않습니다.
    # 따라서 함수 정의에서 'path'를 제거해야 합니다.
    async def handler(self, websocket):
        logging.info(f"새로운 클라이언트 연결됨: {websocket.remote_address}")
        consumer_task = asyncio.ensure_future(self.consumer(websocket))
        producer_task = asyncio.ensure_future(self.producer(websocket))
        done, pending = await asyncio.wait(
            [consumer_task, producer_task],
            return_when=asyncio.FIRST_COMPLETED,
        )
        for task in pending:
            task.cancel()
        logging.info(f"클라이언트 연결 종료: {websocket.remote_address}")

    async def consumer(self, websocket):
        """메인 스레드로부터 명령을 받아 클라이언트로 전송"""
        while True:
            try:
                command = await self.loop.run_in_executor(None, self.command_queue.get)
                await websocket.send(json.dumps(command))
            except (websockets.exceptions.ConnectionClosed, asyncio.CancelledError):
                break

    async def producer(self, websocket):
        """클라이언트로부터 관측 데이터를 받아 메인 스레드로 전송"""
        while True:
            try:
                message = await websocket.recv()
                data = json.loads(message)
                if "observation" in data:
                    obs = np.array(data["observation"], dtype=np.float64)
                    reward = data.get("reward", 0)
                    done = data.get("done", False)
                    self.observation_queue.put((obs, reward, done))
            except (websockets.exceptions.ConnectionClosed, asyncio.CancelledError):
                break

    def run(self):
        asyncio.set_event_loop(self.loop)
        async def start_server():
            async with websockets.serve(self.handler, "localhost", 8765):
                await asyncio.Future()
        try:
            # run_until_complete() 대신 run()을 사용하여 서버를 실행합니다.
            # 이 방식은 스레드 종료 시 더 깔끔하게 처리됩니다.
            self.loop.run_until_complete(start_server())
        finally:
            self.loop.close()

    def stop(self):
        if self.loop.is_running():
            self.loop.call_soon_threadsafe(self.loop.stop)
            logging.info("웹소켓 서버 종료를 시도합니다.")

class WebSocketEnv(gym.Env):
    def __init__(self, observation_shape, action_space_config):
        super(WebSocketEnv, self).__init__()
        self.observation_space = spaces.Box(low=-np.inf, high=np.inf, shape=observation_shape, dtype=np.float64)
        if action_space_config['type'] == 'discrete': self.action_space = spaces.Discrete(action_space_config['n'])
        elif action_space_config['type'] == 'continuous': self.action_space = spaces.Box(low=np.array(action_space_config['low']), high=np.array(action_space_config['high']), dtype=np.float64)
        self.server_thread = ServerThread()
        self.server_thread.start()
        print("첫 클라이언트의 연결 및 데이터 수신을 기다립니다...")

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)
        with self.server_thread.observation_queue.mutex:
            self.server_thread.observation_queue.queue.clear()
        self.server_thread.command_queue.put({"command": "reset"})
        obs, _, _ = self.server_thread.observation_queue.get()
        return obs, {}

    def step(self, action):
        action_to_send = action.tolist() if isinstance(action, np.ndarray) else float(action)
        self.server_thread.command_queue.put({"command": "action", "action": action_to_send})
        obs, reward, done = self.server_thread.observation_queue.get()
        return obs, reward, done, False, {}

    def render(self, mode='human'): pass
   
    def close(self):
        self.server_thread.stop()
        print("웹소켓 서버가 종료되었습니다.")

단계 4: AI 모델 학습 및 ONNX 변환

파일 생성 및 라이브러리 설치

  1. 아래 탭에서 자신의 게임 종류에 맞는 스크립트 코드를 복사하고 프로젝트 폴더에 파일로 저장합니다.
  2. 터미널에서 `pip install stable-baselines3[extra] torch onnx onnxruntime tensorboard` 명령어로 라이브러리를 설치합니다.
# train_simple.py
import os, time, torch, webbrowser, threading
import http.server, socketserver
from stable_baselines3 import PPO
from stable_baselines3.common.callbacks import CheckpointCallback, ProgressBarCallback, CallbackList
from stable_baselines3.common.logger import configure
from websocket_env import WebSocketEnv

# --- 중요 설정 ---
# 1. 관찰 공간 (Observation Space) - AI의 '눈'
OBSERVATION_SHAPE = (4,)
# OBSERVATION_SHAPE = (1, 84, 84) # CnnPolicy를 위한 이미지 관찰 예시
# 2. 행동 공간 (Action Space) - AI의 '손'
# ACTION_SPACE_CONFIG = {'type': 'discrete', 'n': 5}
ACTION_SPACE_CONFIG = {'type': 'continuous', 'low': [-2.0], 'high': [2.0]} # 1차원 연속 행동 예시
# ACTION_SPACE_CONFIG = {'type': 'continuous', 'low': [-1.0, -1.0, -1.0, 0.0], 'high': [1.0, 1.0, 1.0, 1.0]} # 2차원 연속 행동 예시

# -----------------
LOG_DIR, TOTAL_TIMESTEPS, PORT = "training_logs", 50_000_000, 8000
os.makedirs(LOG_DIR, exist_ok=True)

class WebServerThread(threading.Thread):
    def __init__(self, port): super().__init__(); self.port, self.server, self.daemon = port, None, True
    def run(self): self.server = socketserver.TCPServer(("", self.port), http.server.SimpleHTTPRequestHandler); self.server.serve_forever()
    def stop(self):
        if self.server: self.server.shutdown()


def find_latest_run_and_checkpoint():
    if not os.path.isdir(LOG_DIR): return None, None
    runs = sorted([d for d in os.listdir(LOG_DIR) if os.path.isdir(os.path.join(LOG_DIR, d))], reverse=True)
    for run in runs:
        run_path = os.path.join(LOG_DIR, run)
        checkpoints_path = os.path.join(run_path, "checkpoints")
        if not os.path.isdir(checkpoints_path): continue
        checkpoints = [f for f in os.listdir(checkpoints_path) if f.startswith("rl_model_") and f.endswith(".zip")]
        if checkpoints:
            latest_checkpoint = max(checkpoints, key=lambda f: int(f.split("_")[2].replace(".zip", "")))
            return run, os.path.join(checkpoints_path, latest_checkpoint)
    return None, None

def main():
    web_server = WebServerThread(PORT)
    web_server.start()
    time.sleep(1)
    webbrowser.open(f'http://localhost:{PORT}')
   
    latest_run_name, latest_checkpoint = find_latest_run_and_checkpoint()
   
    TENSORBOARD_LOG_NAME = latest_run_name if latest_run_name else f"PPO_{int(time.time())}"
    run_path = os.path.join(LOG_DIR, TENSORBOARD_LOG_NAME)
   
    checkpoint_path = os.path.join(run_path, "checkpoints")
    os.makedirs(checkpoint_path, exist_ok=True)
   
    env = WebSocketEnv(observation_shape=OBSERVATION_SHAPE, action_space_config=ACTION_SPACE_CONFIG)
    custom_logger = configure(run_path, ["stdout", "tensorboard"])
   
    checkpoint_callback = CheckpointCallback(save_freq=20000, save_path=checkpoint_path, name_prefix="rl_model")
    progress_callback = ProgressBarCallback()
    callback_list = CallbackList([checkpoint_callback, progress_callback])
   
    policy_type = "CnnPolicy" if len(OBSERVATION_SHAPE) == 3 else "MlpPolicy"
   
    if latest_checkpoint:
        print(f"체크포인트에서 학습을 재개합니다: {latest_checkpoint}")
        model = PPO.load(latest_checkpoint, env=env, verbose=0, device='auto')
    else:
        print(f"{policy_type} 정책으로 새로운 학습 세션을 시작합니다.")
        model = PPO(policy_type, env, verbose=0, device='auto') # verbose=0 for clean progress bar
       
    model.set_logger(custom_logger)
    final_model_path = ""
    try:
        model.learn(total_timesteps=TOTAL_TIMESTEPS, callback=callback_list, reset_num_timesteps=not bool(latest_checkpoint))
        final_model_path = os.path.join(run_path, "final_model.zip")
        model.save(final_model_path)
        print(f"\n최종 모델 저장 완료: {final_model_path}")
    finally:
        env.close()
        web_server.stop()

if __name__ == '__main__':
    main()
# train_selfplay.py
# train_selfplay.py
import os
import time
import torch
import webbrowser
import threading
import http.server
import socketserver
import shutil
from stable_baselines3 import PPO
from stable_baselines3.common.callbacks import ProgressBarCallback
from stable_baselines3.common.logger import configure
from websocket_env import WebSocketEnv

# --- 중요 설정 ---
# 1. 관찰 공간 (Observation Space) - AI의 '눈'
#    (예: 플레이어1 위치,속도 + 플레이어2 위치,속도 + 공 위치,속도 등)
OBSERVATION_SHAPE = (10,) 

# 2. 행동 공간 (Action Space) - AI의 '손'
#    (예: 위, 아래, 가만히 있기)
ACTION_SPACE_CONFIG = {'type': 'discrete', 'n': 3}

# 3. 셀프 플레이 설정
TOTAL_TRAINING_ITERATIONS = 20  # 총 학습 반복 횟수
STEPS_PER_ITERATION = 50_000    # 한 반복 당 학습할 타임스텝 수 (이 값마다 상대방 모델이 업데이트됨)
TOTAL_TIMESTEPS = TOTAL_TRAINING_ITERATIONS * STEPS_PER_ITERATION

# 4. 기타 설정
LOG_DIR = "training_logs_selfplay"
PORT = 8000
# -----------------

os.makedirs(LOG_DIR, exist_ok=True)

class WebServerThread(threading.Thread):
    """백그라운드에서 로컬 웹 서버를 실행하는 스레드"""
    def __init__(self, port):
        super().__init__()
        self.port = port
        self.server = None
        self.daemon = True
    def run(self):
        handler = http.server.SimpleHTTPRequestHandler
        self.server = socketserver.TCPServer(("", self.port), handler)
        print(f"로컬 서버가 http://localhost:{self.port} 에서 시작되었습니다.")
        self.server.serve_forever()
    def stop(self):
        if self.server:
            self.server.shutdown()
            print("로컬 서버가 종료되었습니다.")

def update_opponent_model(model_zip_path, observation_shape):
    """
    주어진 PPO 모델(.zip)을 'opponent.onnx'로 변환하여 루트 폴더에 저장합니다.
    JS 게임은 이 파일을 상대방 AI로 사용합니다.
    """
    if not os.path.exists(model_zip_path):
        print(f"경고: 상대방 모델로 업데이트할 '{model_zip_path}' 파일을 찾을 수 없습니다. 건너뜁니다.")
        return
        
    print(f"\n--- 상대방 모델 업데이트 시작 ---")
    print(f"'{model_zip_path}' 모델을 ONNX로 변환합니다...")

    try:
        model = PPO.load(model_zip_path, device='cpu')
        model.policy.eval()

        # ONNX 변환을 위한 더미 입력 데이터 생성
        # (배치 크기 1을 추가하여 [1, obs_dim] 형태로 만듦)
        dummy_input = torch.randn((1, *observation_shape), dtype=torch.float32)
        output_path = "opponent.onnx"

        torch.onnx.export(
            model.policy,
            dummy_input,
            output_path,
            opset_version=11,
            input_names=['input'],
            output_names=['output'],
            dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}}
        )
        print(f" 성공! '{output_path}' 파일이 업데이트 되었습니다.")
        print(f"다음 학습부터 에이전트는 더 강해진 자신과 대결합니다.")
        print(f"---------------------------------\n")

    except Exception as e:
        print(f" 상대방 모델 업데이트 중 오류 발생: {e}")

def find_latest_model_and_opponent():
    """가장 최근에 학습된 모델과 상대방 모델을 찾습니다."""
    if not os.path.isdir(LOG_DIR):
        return None, None

    # 가장 최근의 학습 세션 폴더 찾기
    runs = sorted([d for d in os.listdir(LOG_DIR) if d.startswith("PPO_SelfPlay")], reverse=True)
    if not runs:
        return None, None
    
    latest_run_path = os.path.join(LOG_DIR, runs[0])
    
    # 해당 세션의 최종 모델과 최종 상대방 모델 찾기
    latest_model = os.path.join(latest_run_path, "latest_model.zip")
    latest_opponent = os.path.join(latest_run_path, "latest_opponent.onnx")

    if os.path.exists(latest_model):
        # 프로젝트 루트에 이전 상대방 모델 복사
        if os.path.exists(latest_opponent):
            shutil.copyfile(latest_opponent, "opponent.onnx")
        return latest_model, latest_opponent
        
    return None, None

def main():
    # 1. 웹서버 시작 및 브라우저 열기
    web_server = WebServerThread(PORT)
    web_server.start()
    time.sleep(1)
    webbrowser.open(f'http://localhost:{PORT}/?mode=TRAINING') # TRAINING 모드로 바로 진입
    
    # 2. 학습 환경 및 모델 설정
    env = WebSocketEnv(observation_shape=OBSERVATION_SHAPE, action_space_config=ACTION_SPACE_CONFIG)
    
    TENSORBOARD_LOG_NAME = f"PPO_SelfPlay_{int(time.time())}"
    run_path = os.path.join(LOG_DIR, TENSORBOARD_LOG_NAME)
    os.makedirs(run_path, exist_ok=True)
    
    custom_logger = configure(run_path, ["stdout", "tensorboard"])
    
    # 이전 학습 상태 불러오기
    latest_model_path, _ = find_latest_model_and_opponent()
    
    if latest_model_path:
        print(f"이전 학습 모델에서 훈련을 재개합니다: {latest_model_path}")
        model = PPO.load(latest_model_path, env=env, device='auto')
    else:
        print("새로운 셀프 플레이 학습 세션을 시작합니다.")
        # 처음에는 상대가 없으므로, JS에서 랜덤 또는 기본 AI로 작동해야 함
        if os.path.exists("opponent.onnx"):
            print("기존 'opponent.onnx' 파일을 사용합니다.")
        else:
            print("경고: 'opponent.onnx' 파일이 없습니다. JS 환경에서 상대방이 멈춰있을 수 있습니다.")
        model = PPO("MlpPolicy", env, verbose=0, device='auto')
        
    model.set_logger(custom_logger)

    try:
        # 3. 셀프 플레이 학습 루프
        for i in range(TOTAL_TRAINING_ITERATIONS):
            print(f"=============== 셀프 플레이 반복 [{i+1}/{TOTAL_TRAINING_ITERATIONS}] ===============")
            
            # reset_num_timesteps=False로 설정하여 학습을 이어나감
            model.learn(total_timesteps=STEPS_PER_ITERATION, callback=ProgressBarCallback(), reset_num_timesteps=False)

            # 현재까지 학습된 모델을 저장
            current_model_path = os.path.join(run_path, "latest_model.zip")
            model.save(current_model_path)
            
            # 저장된 모델을 opponent.onnx로 변환 및 복사
            update_opponent_model(current_model_path, OBSERVATION_SHAPE)

            # 만약을 위해 변환된 onnx 파일도 백업
            shutil.copyfile("opponent.onnx", os.path.join(run_path, "latest_opponent.onnx"))

        print("\n\n 모든 셀프 플레이 학습이 완료되었습니다! ")
        final_model_path = os.path.join(run_path, "final_model.zip")
        model.save(final_model_path)
        print(f"최종 모델이 '{final_model_path}'에 저장되었습니다.")
        print(f"최종 상대방 모델은 프로젝트 루트의 'opponent.onnx' 입니다.")
        print("이제 index.html을 열고 AI 모드로 최종 결과를 확인하세요.")

    except KeyboardInterrupt:
        print("\n학습이 중단되었습니다. 현재까지의 진행 상황을 저장합니다.")
        model.save(os.path.join(run_path, "interrupted_model.zip"))
    finally:
        env.close()
        web_server.stop()

if __name__ == '__main__':
    main()

AI 설정 가이드

관찰 공간(Observation Space) - AI의 '눈'
  • 벡터 관찰: `(숫자,)` 형태 (예: `(8,)`). AI는 `[좌표, 속도]` 등 숫자 리스트로 상태를 인식합니다. 이때 SB3는 자동으로 **MlpPolicy**를 사용합니다.
  • 이미지 관찰: `(채널, 높이, 너비)` 형태 (예: `(1, 84, 84)`). AI는 게임 화면 픽셀 자체를 보고 학습합니다. 이때 SB3는 자동으로 **CnnPolicy**를 사용합니다.
행동 공간(Action Space) - AI의 '손'
  • 이산(Discrete) 행동: "점프", "왼쪽" 같이 버튼처럼 딱딱 끊어지는 행동입니다. JS의 `applyAction`은 정수(`0, 1, 2...`)를 받습니다.
  • 연속(Continuous) 행동: 로켓 추진력(-1.0 ~ 1.0)처럼 세밀하고 연속적인 값으로 조작합니다. JS의 `applyAction`은 숫자 배열(`[-0.5, 0.88]`)을 받습니다.

단계 5: 최종 AI 게임 테스트

Onnx변환 후 AI 플레이 감상하기

이제 모든 준비가 끝났습니다. 학습의 최종 결과물인 `model.onnx`가 얼마나 똑똑해졌는지 확인해볼 시간입니다.

  1. `convert_onnx.py`를 프로젝트 폴더에 새로 만들어 아래의 코드를 붙여넣습니다.
  2. 프로젝트 폴더에서 training_logs/PPO_숫자/ 폴더에서 `final_model.zip` 파일을 찾습니다.
  3. `final_model.zip` 파일을 우클릭하여 상대경로를 복사하여 convert_onnx.py 내부의 MODEL_DIR="경로"의 경로에 붙여넣어 수정합니다. (이때 파일 이름은 지우고 폴더 경로만 붙여넣기)
  4. `model.onnx` 파일이 나오면 프로젝트 폴더에서 터미널을 열고 python -m http.server 명령어로 로컬 서버를 실행합니다.
  5. 웹 브라우저에서 http://localhost:8000 으로 접속합니다.
  6. 게임이 로드되면, AI 모드 버튼을 클릭하여 학습된 모델의 플레이를 감상하세요!
import torch
import torch.nn as nn
from stable_baselines3 import PPO
import numpy as np
import os

# --- 설정 ---
MODEL_DIR = "model"  # 모델이 저장된 폴더 경로
MODEL_FILENAME = "final_model.zip"
MODEL_PATH = os.path.join(MODEL_DIR, MODEL_FILENAME)

# 변환된 ONNX 모델을 저장할 경로와 파일명
# index.html 파일이 있는 루트 폴더에 저장해야 AI 모드가 인식할 수 있습니다.
ONNX_OUTPUT_PATH = "model.onnx"

# train.py에 정의된 환경의 관찰 공간 모양
# (배치 크기 1을 추가하여 [1, 20] 형태로 만들어야 합니다)
OBSERVATION_SHAPE = (1, 20) 

def convert_to_onnx():
    """
    Stable-Baselines3 PPO 모델(.zip)을 ONNX 형식으로 변환합니다.
    """
    print(f"'{MODEL_PATH}'에서 학습된 모델을 불러옵니다...")

    # 모델 파일이 존재하는지 확인
    if not os.path.exists(MODEL_PATH):
        print(f"오류: 모델 파일 '{MODEL_PATH}'을 찾을 수 없습니다.")
        print("먼저 train.py를 실행하여 모델을 학습시키고 저장해야 합니다.")
        return

    # Stable-Baselines3 모델 불러오기
    # PyTorch를 사용하도록 device='cpu'로 명시하는 것이 안정적입니다.
    try:
        model = PPO.load(MODEL_PATH, device='cpu')
    except Exception as e:
        print(f"모델을 불러오는 중 오류가 발생했습니다: {e}")
        return
        
    print("모델을 성공적으로 불러왔습니다.")

    # ONNX 변환을 위해 모델을 평가 모드(evaluation mode)로 설정합니다.
    # 이 과정은 드롭아웃(dropout)이나 배치 정규화(batch normalization) 등의
    # 학습용 레이어를 비활성화하여 일관된 출력을 보장합니다.
    model.policy.eval()

    # ONNX 변환을 위한 더미 입력 데이터를 생성합니다.
    # 이 데이터는 모델의 구조를 추적하는 데 사용되며, 실제 입력값은 중요하지 않습니다.
    dummy_input = torch.randn(OBSERVATION_SHAPE, dtype=torch.float32)

    print(f"모델을 ONNX 형식으로 변환합니다. 저장 위치: '{ONNX_OUTPUT_PATH}'")

    try:
        # torch.onnx.export 함수를 사용하여 모델을 변환합니다.
        torch.onnx.export(
            model.policy,                 # 변환할 모델의 정책 네트워크
            dummy_input,                  # 모델 구조 추적을 위한 더미 입력
            ONNX_OUTPUT_PATH,             # 저장될 ONNX 파일 경로
            opset_version=11,             # ONNX 버전 (11은 호환성이 좋음)
            input_names=['input'],        # ONNX 모델의 입력 레이어 이름
            output_names=['output'],      # ONNX 모델의 출력 레이어 이름
            dynamic_axes={'input': {0: 'batch_size'}, # 배치 크기가 변할 수 있도록 설정
                          'output': {0: 'batch_size'}}
        )
        print("-" * 50)
        print(" ONNX 변환이 성공적으로 완료되었습니다!")
        print(f"이제 'index.html' 파일을 브라우저에서 열고 'AI 모드'를 테스트할 수 있습니다.")
        print("-" * 50)

    except Exception as e:
        print(f"ONNX 변환 중 오류가 발생했습니다: {e}")

if __name__ == '__main__':
    convert_to_onnx()

단계 6: GitHub Pages 배포

최종 단계입니다. 완성된 프로젝트를 VS Code에서 바로 GitHub에 올리고, GitHub Pages를 통해 전 세계에 배포합니다.

1. 사전 준비: Git 설치 및 설정

Git이 설치되어 있지 않다면 git-scm.com에서 다운로드하여 설치합니다. 설치 후, 터미널을 열어 아래 명령어를 입력하여 사용자 정보를 설정합니다 (최초 한 번만 필요).

git config --global user.name "Your Name"
git config --global user.email "you@example.com"

2. VS Code에서 GitHub 계정 연동

VS Code 왼쪽 하단의 사람 모양 아이콘(계정)을 클릭하고, 'GitHub으로 로그인(Sign in with GitHub)'을 선택하여 브라우저의 안내에 따라 계정을 연동합니다.

3. Git 저장소 초기화 및 첫 커밋

VS Code 왼쪽의 '소스 제어' 탭(가지 모양 아이콘)으로 이동하여 '리포지토리 초기화(Initialize Repository)' 버튼을 클릭합니다. 그 후, 상단의 메시지 입력란에 "Initial commit"과 같은 첫 커밋 메시지를 작성한 후 ✓ 커밋 버튼을 눌러 프로젝트 파일(`index.html`, `model.onnx` 등)을 커밋합니다.

4. GitHub에 게시하기

'소스 제어' 탭에 나타나는 'GitHub에 게시(Publish to GitHub)' 버튼을 클릭합니다. VS Code가 GitHub에 저장소를 만드는 과정을 안내합니다. 저장소 이름을 정하고, 반드시 '공개(Public)' 저장소로 설정해야 GitHub Pages를 무료로 사용할 수 있습니다.

5. GitHub Pages 활성화

게시가 완료되면 GitHub 사이트에서 방금 생성한 저장소로 이동합니다. 'Settings' → 'Pages' 탭으로 이동하여 'Source'를 'Deploy from a branch'로, 브랜치를 'main' (또는 'master')으로 선택하고 'Save'를 클릭합니다.

6. 배포 확인 및 공유

잠시 후 페이지 상단에 녹색으로 게시 주소(https://<사용자명>.github.io/<저장소명>/)가 나타납니다. 이 주소로 접속하여 게임을 확인하고 전 세계와 공유하세요!

단계 7: 예제 파일 다운로드 (CartPole)

이 가이드를 통해 최종적으로 완성된 CartPole 게임의 파일입니다. 학습 파일은 제공되지 않았습니다. 직접 학습시켜보세요. 전체 코드를 직접 확인하고 싶거나, 자신의 프로젝트와 비교하며 참고하고 싶을 때 아래 버튼을 통해 다운로드하세요.