| """ |
| =================================================================================================== |
| 🏓 MRPONG: PUBLIC HUGGING FACE INFERENCE & INTERACTIVE GAMEPLAY SCRIPT |
| =================================================================================================== |
| Model Repository: https://huggingface.co/fromziro/MrPong |
| |
| Zero-dependency, standalone script for the public to: |
| 1. Play against MrPong live in the terminal (Real-time hold-to-move keyboard control) |
| 2. Run match simulations against built-in AI baseline opponents |
| |
| Quickstart: |
| pip install torch transformers |
| python hf_inference.py --mode play |
| python hf_inference.py --mode simulate --opponent realistic_hard --matches 20 |
| """ |
|
|
| import os |
| import sys |
| import time |
| import math |
| import random |
| import argparse |
| from dataclasses import dataclass |
| from typing import Optional, Tuple, Dict, Any, List |
| import numpy as np |
| import torch |
|
|
| try: |
| from transformers import AutoModel, AutoConfig |
| except ImportError: |
| print("[!] Error: 'transformers' is required. Run: pip install transformers torch") |
| sys.exit(1) |
|
|
| |
| IS_WINDOWS = sys.platform.startswith("win") |
| if IS_WINDOWS: |
| import ctypes |
| HAS_KEYBOARD = True |
| else: |
| try: |
| import select |
| import tty |
| import termios |
| HAS_KEYBOARD = True |
| except ImportError: |
| HAS_KEYBOARD = False |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class PhysicsConfig: |
| table_width: float = 800.0 |
| table_height: float = 500.0 |
| paddle_width: float = 14.0 |
| paddle_height: float = 80.0 |
| paddle_speed: float = 8.0 |
| paddle_smoothing: float = 0.70 |
| ball_radius: float = 8.0 |
| ball_speed_initial: float = 8.0 |
| ball_speed_max: float = 16.0 |
| ball_acceleration: float = 1.035 |
| frame_skip: int = 3 |
| max_rally_steps: int = 1500 |
|
|
|
|
| class StandalonePongEnv: |
| """Self-contained table tennis physics environment for public standalone execution.""" |
| def __init__(self, phys: Optional[PhysicsConfig] = None, seed: Optional[int] = None): |
| self.phys = phys or PhysicsConfig() |
| self.rng = random.Random(seed) |
| self.ego_paddle_h = self.phys.paddle_height |
| self.opp_paddle_h = self.phys.paddle_height |
| self.reset() |
|
|
| def reset(self, serve_direction: Optional[int] = None, initial_speed: Optional[float] = None) -> np.ndarray: |
| self.ego_y = self.phys.table_height / 2.0 |
| self.opp_y = self.phys.table_height / 2.0 |
| self.ego_vy = 0.0 |
| self.opp_vy = 0.0 |
| self.prev_ego_action = 0 |
|
|
| self.ball_x = self.phys.table_width / 2.0 |
| self.ball_y = self.phys.table_height / 2.0 |
|
|
| if serve_direction is None: |
| serve_direction = 1 if self.rng.random() < 0.5 else -1 |
|
|
| serve_angle = self.rng.uniform(-math.pi / 7.0, math.pi / 7.0) |
| speed = initial_speed or self.phys.ball_speed_initial |
| self.ball_vx = serve_direction * speed * math.cos(serve_angle) |
| self.ball_vy = speed * math.sin(serve_angle) |
| self.rally_count = 0 |
| self.step_count = 0 |
|
|
| return self.get_ego_observation() |
|
|
| def _get_action_velocity(self, action: int) -> float: |
| if action == 1: |
| return -self.phys.paddle_speed |
| elif action == 2: |
| return self.phys.paddle_speed |
| return 0.0 |
|
|
| def physics_substep(self, ego_action: int, opp_action: int) -> Tuple[bool, Dict[str, Any]]: |
| """Executes a single continuous physics sub-step (fluid momentum + Continuous Collision Detection).""" |
| info = {"winner": None} |
| done = False |
|
|
| prev_ego_y = self.ego_y |
| prev_opp_y = self.opp_y |
|
|
| ego_target_v = self._get_action_velocity(ego_action) |
| opp_target_v = self._get_action_velocity(opp_action) |
|
|
| alpha = self.phys.paddle_smoothing |
| self.ego_vy = alpha * self.ego_vy + (1.0 - alpha) * ego_target_v |
| self.opp_vy = alpha * self.opp_vy + (1.0 - alpha) * opp_target_v |
|
|
| ego_half_h = self.ego_paddle_h / 2.0 |
| opp_half_h = self.opp_paddle_h / 2.0 |
|
|
| self.ego_y = float(np.clip(self.ego_y + self.ego_vy, ego_half_h, self.phys.table_height - ego_half_h)) |
| self.opp_y = float(np.clip(self.opp_y + self.opp_vy, opp_half_h, self.phys.table_height - opp_half_h)) |
|
|
| prev_ball_x = self.ball_x |
| prev_ball_y = self.ball_y |
| r = self.phys.ball_radius |
|
|
| ego_paddle_x = self.phys.paddle_width |
| opp_paddle_x = self.phys.table_width - self.phys.paddle_width |
| ego_impact_plane = ego_paddle_x + r |
| opp_impact_plane = opp_paddle_x - r |
|
|
| next_ball_x = prev_ball_x + self.ball_vx |
| next_ball_y = prev_ball_y + self.ball_vy |
|
|
| hit_occurred = False |
|
|
| |
| if self.ball_vx < 0 and prev_ball_x >= ego_impact_plane and next_ball_x <= ego_impact_plane: |
| t = float(np.clip((prev_ball_x - ego_impact_plane) / max(1e-6, -self.ball_vx), 0.0, 1.0)) |
| y_ball_at_impact = prev_ball_y + t * self.ball_vy |
| y_ego_at_impact = prev_ego_y + t * (self.ego_y - prev_ego_y) |
|
|
| if abs(y_ball_at_impact - y_ego_at_impact) <= (ego_half_h + r * 0.6): |
| hit_occurred = True |
| self.rally_count += 1 |
| offset = float(np.clip((y_ball_at_impact - y_ego_at_impact) / ego_half_h, -1.0, 1.0)) |
| bounce_angle = offset * (math.pi / 3.0) |
| current_speed = math.hypot(self.ball_vx, self.ball_vy) |
| new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max) |
| new_vx = new_speed * math.cos(bounce_angle) |
| new_vy = new_speed * math.sin(bounce_angle) + 0.25 * self.ego_vy |
|
|
| rem_dt = 1.0 - t |
| self.ball_x = ego_impact_plane + rem_dt * new_vx |
| self.ball_y = y_ball_at_impact + rem_dt * new_vy |
| self.ball_vx = new_vx |
| self.ball_vy = new_vy |
|
|
| |
| elif self.ball_vx > 0 and prev_ball_x <= opp_impact_plane and next_ball_x >= opp_impact_plane: |
| t = float(np.clip((opp_impact_plane - prev_ball_x) / max(1e-6, self.ball_vx), 0.0, 1.0)) |
| y_ball_at_impact = prev_ball_y + t * self.ball_vy |
| y_opp_at_impact = prev_opp_y + t * (self.opp_y - prev_opp_y) |
|
|
| if abs(y_ball_at_impact - y_opp_at_impact) <= (opp_half_h + r * 0.6): |
| hit_occurred = True |
| self.rally_count += 1 |
| offset = float(np.clip((y_ball_at_impact - y_opp_at_impact) / opp_half_h, -1.0, 1.0)) |
| bounce_angle = offset * (math.pi / 3.0) |
| current_speed = math.hypot(self.ball_vx, self.ball_vy) |
| new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max) |
| new_vx = -new_speed * math.cos(bounce_angle) |
| new_vy = new_speed * math.sin(bounce_angle) + 0.25 * self.opp_vy |
|
|
| rem_dt = 1.0 - t |
| self.ball_x = opp_impact_plane + rem_dt * new_vx |
| self.ball_y = y_ball_at_impact + rem_dt * new_vy |
| self.ball_vx = new_vx |
| self.ball_vy = new_vy |
|
|
| if not hit_occurred: |
| self.ball_x = next_ball_x |
| self.ball_y = next_ball_y |
|
|
| |
| if self.ball_y - r <= 0: |
| self.ball_y = r + abs(r - self.ball_y) |
| self.ball_vy = abs(self.ball_vy) |
| elif self.ball_y + r >= self.phys.table_height: |
| self.ball_y = (self.phys.table_height - r) - abs(self.ball_y + r - self.phys.table_height) |
| self.ball_vy = -abs(self.ball_vy) |
|
|
| |
| if self.ball_x - r < 0: |
| done = True |
| info["winner"] = "opponent" |
| elif self.ball_x + r > self.phys.table_width: |
| done = True |
| info["winner"] = "ego" |
|
|
| self.prev_ego_action = ego_action |
| return done, info |
|
|
| def step(self, ego_action: int, opp_action: int) -> Tuple[np.ndarray, bool, Dict[str, Any]]: |
| self.step_count += 1 |
| done = False |
| info = {"winner": None} |
|
|
| for _ in range(self.phys.frame_skip): |
| d, sub_info = self.physics_substep(ego_action, opp_action) |
| if d: |
| done = True |
| info = sub_info |
| break |
|
|
| if not done and self.step_count >= self.phys.max_rally_steps: |
| done = True |
| info["winner"] = "draw" |
|
|
| return self.get_ego_observation(), done, info |
|
|
| def calculate_intercept_y(self, target_x: float, ball_x: float, ball_y: float, ball_vx: float, ball_vy: float) -> float: |
| if (target_x > ball_x and ball_vx <= 0) or (target_x < ball_x and ball_vx >= 0): |
| return self.phys.table_height / 2.0 |
|
|
| bx, by = float(ball_x), float(ball_y) |
| bvx, bvy = float(ball_vx), float(ball_vy) |
| h = self.phys.table_height |
| r = self.phys.ball_radius |
|
|
| for _ in range(10): |
| dt_x = (target_x - bx) / bvx if bvx != 0 else float('inf') |
| if dt_x <= 0: |
| break |
| if bvy > 0: |
| dt_y = (h - r - by) / bvy |
| elif bvy < 0: |
| dt_y = (r - by) / bvy |
| else: |
| dt_y = float('inf') |
|
|
| if dt_x <= dt_y: |
| by += bvy * dt_x |
| break |
| else: |
| bx += bvx * dt_y |
| by += bvy * dt_y |
| bvy = -bvy |
|
|
| return float(np.clip(by, r, h - r)) |
|
|
| def get_ego_observation(self) -> np.ndarray: |
| w, h = self.phys.table_width, self.phys.table_height |
| v_max = self.phys.ball_speed_max |
| pv_max = self.phys.paddle_speed |
| half_h = self.ego_paddle_h / 2.0 |
| ego_x = self.phys.paddle_width |
|
|
| pred_intercept_y = self.calculate_intercept_y(ego_x, self.ball_x, self.ball_y, self.ball_vx, self.ball_vy) |
| rel_pred_y = (pred_intercept_y - self.ego_y) / h |
| pred_norm_y = pred_intercept_y / h |
|
|
| opp_y_norm = self.opp_y / h |
| opp_open_top = (self.opp_y - half_h) / h |
| opp_open_bottom = (h - (self.opp_y + half_h)) / h |
| speed_norm = math.hypot(self.ball_vx, self.ball_vy) / v_max |
|
|
| return np.array([ |
| (self.ball_y - self.ego_y) / h, |
| (self.ball_x - ego_x) / w, |
| self.ball_vx / v_max, |
| self.ball_vy / v_max, |
| self.ego_y / h, |
| self.ego_vy / pv_max, |
| (self.opp_y - self.ego_y) / h, |
| self.opp_vy / pv_max, |
| self.ball_y / h, |
| self.ball_x / w, |
| rel_pred_y, |
| pred_norm_y, |
| opp_y_norm, |
| opp_open_top, |
| opp_open_bottom, |
| speed_norm |
| ], dtype=np.float32) |
|
|
| def get_opp_observation(self) -> np.ndarray: |
| w, h = self.phys.table_width, self.phys.table_height |
| v_max = self.phys.ball_speed_max |
| pv_max = self.phys.paddle_speed |
| half_h = self.opp_paddle_h / 2.0 |
| opp_x = self.phys.table_width - self.phys.paddle_width |
|
|
| pred_intercept_y = self.calculate_intercept_y(opp_x, self.ball_x, self.ball_y, self.ball_vx, self.ball_vy) |
| rel_pred_y = (pred_intercept_y - self.opp_y) / h |
| pred_norm_y = pred_intercept_y / h |
|
|
| ego_y_norm = self.ego_y / h |
| ego_open_top = (self.ego_y - half_h) / h |
| ego_open_bottom = (h - (self.ego_y + half_h)) / h |
| speed_norm = math.hypot(self.ball_vx, self.ball_vy) / v_max |
|
|
| return np.array([ |
| (self.ball_y - self.opp_y) / h, |
| (opp_x - self.ball_x) / w, |
| -self.ball_vx / v_max, |
| self.ball_vy / v_max, |
| self.opp_y / h, |
| self.opp_vy / pv_max, |
| (self.ego_y - self.opp_y) / h, |
| self.ego_vy / pv_max, |
| self.ball_y / h, |
| (w - self.ball_x) / w, |
| rel_pred_y, |
| pred_norm_y, |
| ego_y_norm, |
| ego_open_top, |
| ego_open_bottom, |
| speed_norm |
| ], dtype=np.float32) |
|
|
|
|
| |
| |
| |
|
|
| def smooth_aim_action(current_y: float, target_y: float, prev_action: int, deadzone: float = 6.0) -> int: |
| diff = target_y - current_y |
| if abs(diff) < deadzone: |
| return 0 |
| return 2 if diff > 0 else 1 |
|
|
|
|
| class RealisticHardOpponent: |
| def __init__(self, commit_x_ratio: float = 0.60): |
| self.commit_x_ratio = commit_x_ratio |
| self.prev_action = 0 |
| self.perceptual_noise = 0.0 |
|
|
| def act(self, env: StandalonePongEnv) -> int: |
| if env.ball_vx <= 0: |
| target_y = env.phys.table_height / 2.0 |
| self.perceptual_noise = random.uniform(-12.0, 12.0) |
| elif env.ball_x < env.phys.table_width * self.commit_x_ratio: |
| target_y = env.phys.table_height / 2.0 + (env.ball_y - env.phys.table_height / 2.0) * 0.40 |
| else: |
| target_x = env.phys.table_width - env.phys.paddle_width |
| exact_y = env.calculate_intercept_y(target_x, env.ball_x, env.ball_y, env.ball_vx, env.ball_vy) |
| target_y = float(np.clip(exact_y + self.perceptual_noise, 8.0, env.phys.table_height - 8.0)) |
|
|
| action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=7.0) |
| self.prev_action = action |
| return action |
|
|
|
|
| class MediumOpponent: |
| def __init__(self): |
| self.prev_action = 0 |
|
|
| def act(self, env: StandalonePongEnv) -> int: |
| if env.ball_vx <= 0: |
| target_y = env.phys.table_height / 2.0 |
| else: |
| dt = (env.phys.table_width - env.phys.paddle_width - env.ball_x) / max(1.0, env.ball_vx) |
| target_y = env.ball_y + env.ball_vy * dt |
| target_y = float(np.clip(target_y, 0, env.phys.table_height)) |
|
|
| action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=14.0) |
| self.prev_action = action |
| return action |
|
|
|
|
| class EasyOpponent: |
| def __init__(self): |
| self.prev_action = 0 |
|
|
| def act(self, env: StandalonePongEnv) -> int: |
| if env.ball_vx <= 0 or env.ball_x < env.phys.table_width * 0.45: |
| target_y = env.phys.table_height / 2.0 |
| else: |
| target_y = env.ball_y + random.uniform(-30.0, 30.0) |
|
|
| action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=25.0) |
| self.prev_action = action |
| return action |
|
|
|
|
| class ImpossibleHardOpponent: |
| def __init__(self): |
| self.prev_action = 0 |
|
|
| def act(self, env: StandalonePongEnv) -> int: |
| if env.ball_vx <= 0: |
| target_y = env.phys.table_height / 2.0 |
| else: |
| target_x = env.phys.table_width - env.phys.paddle_width |
| target_y = env.calculate_intercept_y(target_x, env.ball_x, env.ball_y, env.ball_vx, env.ball_vy) |
|
|
| action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=2.0) |
| self.prev_action = action |
| return action |
|
|
|
|
| class RandomOpponent: |
| def act(self, env: StandalonePongEnv) -> int: |
| return random.randint(0, 2) |
|
|
|
|
| |
| |
| |
|
|
| class KeyboardController: |
| """ |
| Direct hardware key state listener. |
| Holding W/Up moves UP. Holding S/Down moves DOWN. Releasing stops (STAY). |
| """ |
| def __init__(self): |
| self.is_windows = IS_WINDOWS |
| if self.is_windows: |
| self.user32 = ctypes.windll.user32 |
| |
| self.VK_W = 0x57 |
| self.VK_S = 0x53 |
| self.VK_UP = 0x26 |
| self.VK_DOWN = 0x28 |
| self.VK_Q = 0x51 |
| self.VK_ESCAPE = 0x1B |
| else: |
| self.decay_frames = 0 |
| self.current_act = 0 |
| if HAS_KEYBOARD: |
| self.old_settings = termios.tcgetattr(sys.stdin) |
| tty.setcbreak(sys.stdin.fileno()) |
|
|
| def get_action(self) -> int: |
| """ |
| Returns: |
| 1: UP (while W / Up Arrow is held) |
| 2: DOWN (while S / Down Arrow is held) |
| 0: STAY (when released) |
| -1: QUIT (when Q / ESC is pressed) |
| """ |
| if self.is_windows: |
| |
| if (self.user32.GetAsyncKeyState(self.VK_Q) & 0x8000) or (self.user32.GetAsyncKeyState(self.VK_ESCAPE) & 0x8000): |
| return -1 |
|
|
| |
| w_held = bool((self.user32.GetAsyncKeyState(self.VK_W) & 0x8000) or (self.user32.GetAsyncKeyState(self.VK_UP) & 0x8000)) |
| s_held = bool((self.user32.GetAsyncKeyState(self.VK_S) & 0x8000) or (self.user32.GetAsyncKeyState(self.VK_DOWN) & 0x8000)) |
|
|
| if w_held and not s_held: |
| return 1 |
| elif s_held and not w_held: |
| return 2 |
| else: |
| return 0 |
| else: |
| |
| if not HAS_KEYBOARD: |
| return 0 |
| rlist, _, _ = select.select([sys.stdin], [], [], 0) |
| if rlist: |
| ch = sys.stdin.read(1) |
| if ch in ['w', 'W']: |
| self.current_act = 1 |
| self.decay_frames = 5 |
| elif ch in ['s', 'S']: |
| self.current_act = 2 |
| self.decay_frames = 5 |
| elif ch in ['q', 'Q']: |
| return -1 |
|
|
| if self.decay_frames > 0: |
| self.decay_frames -= 1 |
| return self.current_act |
| else: |
| self.current_act = 0 |
| return 0 |
|
|
| def close(self): |
| if not self.is_windows and HAS_KEYBOARD: |
| try: |
| termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old_settings) |
| except Exception: |
| pass |
|
|
|
|
| |
| |
| |
|
|
| def resolve_model_path(path_or_id: Optional[str] = None) -> str: |
| if path_or_id and os.path.exists(path_or_id): |
| return os.path.abspath(path_or_id) |
|
|
| local_candidates = [ |
| os.path.abspath("./MrPong"), |
| os.path.abspath("C:/Users/harley/MrPong"), |
| os.path.abspath("./mrpong_hf") |
| ] |
| for lp in local_candidates: |
| if os.path.exists(lp) and os.path.exists(os.path.join(lp, "config.json")): |
| if path_or_id is None or path_or_id == "fromziro/MrPong": |
| return lp |
|
|
| return path_or_id or "fromziro/MrPong" |
|
|
|
|
| class PublicMrPongRunner: |
| def __init__(self, model_id_or_path: Optional[str] = None, device: str = "cpu"): |
| self.model_path = resolve_model_path(model_id_or_path) |
| self.device = torch.device(device if torch.cuda.is_available() else "cpu") |
|
|
| print(f"[*] Loading MrPong from: {self.model_path} ...") |
| self.config = AutoConfig.from_pretrained(self.model_path, trust_remote_code=True) |
| self.model = AutoModel.from_pretrained(self.model_path, trust_remote_code=True).to(self.device) |
| self.model.eval() |
|
|
| self.obs_dim = getattr(self.config, "obs_dim", 12) |
| print(f"[OK] MrPong ready! (obs_dim={self.obs_dim}, hidden_dims={self.config.hidden_dims})\n") |
|
|
| def predict_action(self, obs: np.ndarray, deterministic: bool = True) -> int: |
| obs_input = obs[:self.obs_dim] if len(obs) >= self.obs_dim else np.pad(obs, (0, self.obs_dim - len(obs))) |
| return self.model.act(obs_input, deterministic=deterministic) |
|
|
| |
| |
| |
| def play(self, points_to_win: int = 5, difficulty: str = "normal"): |
| phys = PhysicsConfig() |
|
|
| if difficulty == "easy": |
| phys.paddle_speed = 9.0 |
| initial_ball_speed = 3.5 |
| ego_paddle_h = 110.0 |
| frame_delay = 0.028 |
| elif difficulty == "hard": |
| phys.paddle_speed = 8.0 |
| initial_ball_speed = 6.0 |
| ego_paddle_h = 80.0 |
| frame_delay = 0.022 |
| else: |
| phys.paddle_speed = 8.5 |
| initial_ball_speed = 4.2 |
| ego_paddle_h = 95.0 |
| frame_delay = 0.025 |
|
|
| env = StandalonePongEnv(phys) |
| env.ego_paddle_h = ego_paddle_h |
| kbd = KeyboardController() |
|
|
| human_score = 0 |
| ai_score = 0 |
| max_rally = 0 |
|
|
| print("=" * 64) |
| print(f" 🏓 PLAY AGAINST MRPONG (ARCADE MODE - {difficulty.upper()})") |
| print("=" * 64) |
| print(" Controls:") |
| print(" Hold [W] / [Up Arrow] : Move UP") |
| print(" Hold [S] / [Down Arrow] : Move DOWN") |
| print(" Release key : STAY still") |
| print(" [Q] / [Esc] : Quit match") |
| print(f"\n First player to {points_to_win} points wins!") |
| input("\n Press [ENTER] to start match...") |
|
|
| sys.stdout.write("\033[?25l") |
| sys.stdout.flush() |
|
|
| serve_dir = 1 |
| ai_act = 0 |
| substep_count = 0 |
|
|
| try: |
| while human_score < points_to_win and ai_score < points_to_win: |
| obs = env.reset(serve_direction=serve_dir, initial_speed=initial_ball_speed) |
|
|
| for cd in [3, 2, 1]: |
| self._render_terminal_court(env, human_score, ai_score, 0, ai_act, points_to_win, banner=f"GET READY: SERVING IN {cd}...") |
| time.sleep(0.6) |
|
|
| done = False |
|
|
| while not done: |
| t_start = time.perf_counter() |
|
|
| |
| human_act = kbd.get_action() |
| if human_act == -1: |
| print("\n[!] Match aborted by player.") |
| return |
|
|
| |
| if substep_count % env.phys.frame_skip == 0: |
| opp_obs = env.get_opp_observation() |
| ai_act = self.predict_action(opp_obs, deterministic=True) |
|
|
| substep_count += 1 |
|
|
| |
| done, info = env.physics_substep(ego_action=human_act, opp_action=ai_act) |
|
|
| if env.rally_count > max_rally: |
| max_rally = env.rally_count |
|
|
| |
| self._render_terminal_court(env, human_score, ai_score, human_act, ai_act, points_to_win) |
|
|
| |
| t_elapsed = time.perf_counter() - t_start |
| if t_elapsed < frame_delay: |
| time.sleep(frame_delay - t_elapsed) |
|
|
| |
| winner = info.get("winner") |
| if winner == "ego": |
| human_score += 1 |
| serve_dir = 1 |
| banner = ">>> POINT TO YOU! <<<" |
| elif winner == "opponent": |
| ai_score += 1 |
| serve_dir = -1 |
| banner = ">>> POINT TO MRPONG! <<<" |
| else: |
| banner = ">>> RALLY DRAW <<<" |
|
|
| self._render_terminal_court(env, human_score, ai_score, 0, ai_act, points_to_win, banner=banner) |
| time.sleep(1.2) |
|
|
| sys.stdout.write("\033[?25h\033[H\033[J") |
| print("\n" + "=" * 64) |
| if human_score >= points_to_win: |
| print(f" 🏆 CONGRATULATIONS! YOU WON {human_score} - {ai_score}!") |
| else: |
| print(f" 🤖 MRPONG WON {ai_score} - {human_score}!") |
| print(f" Longest Rally: {max_rally} hits") |
| print("=" * 64 + "\n") |
| finally: |
| sys.stdout.write("\033[?25h") |
| sys.stdout.flush() |
| kbd.close() |
|
|
| def _render_terminal_court(self, env: StandalonePongEnv, s1: int, s2: int, a1: int, a2: int, target: int, banner: str = ""): |
| """Atomic flicker-free frame buffer rendering.""" |
| cols, rows = 60, 18 |
| bx = int(np.clip((env.ball_x / env.phys.table_width) * (cols - 1), 0, cols - 1)) |
| by = int(np.clip((env.ball_y / env.phys.table_height) * (rows - 1), 0, rows - 1)) |
| p1_y = int(np.clip((env.ego_y / env.phys.table_height) * (rows - 1), 0, rows - 1)) |
| p2_y = int(np.clip((env.opp_y / env.phys.table_height) * (rows - 1), 0, rows - 1)) |
|
|
| p1_ph = max(1, int((env.ego_paddle_h / env.phys.table_height) * (rows - 1) / 2)) |
| p2_ph = max(1, int((env.opp_paddle_h / env.phys.table_height) * (rows - 1) / 2)) |
|
|
| act_names = ["STAY", " UP ", "DOWN"] |
| buf = [] |
|
|
| buf.append("\033[H") |
| buf.append("+" + "-" * (cols + 2) + "+\n") |
| buf.append(f"| YOU [P1]: {s1}/{target} ({act_names[a1]})" + " " * (cols - 41) + f"MRPONG [AI]: {s2}/{target} ({act_names[a2]}) |\n") |
| buf.append("+" + "-" * (cols + 2) + "+\n") |
|
|
| for r in range(rows): |
| line = ["#" if abs(r - p1_y) <= p1_ph else " "] |
| for c in range(cols): |
| if r == by and c == bx: |
| line.append("O") |
| elif c == cols // 2: |
| line.append(":") |
| else: |
| line.append(" ") |
| line.append("|" if abs(r - p2_y) <= p2_ph else " ") |
| buf.append("|" + "".join(line) + "|\n") |
|
|
| buf.append("+" + "-" * (cols + 2) + "+\n") |
| speed = math.hypot(env.ball_vx, env.ball_vy) |
| if banner: |
| buf.append(f"| {banner:^60} |\n") |
| else: |
| buf.append(f"| Rally: {env.rally_count:2d} hits | Ball Speed: {speed:4.1f} px/f" + " " * (cols - 33) + "|\n") |
| buf.append("+" + "-" * (cols + 2) + "+\n") |
|
|
| sys.stdout.write("".join(buf)) |
| sys.stdout.flush() |
|
|
| |
| |
| |
| def simulate(self, opponent_type: str = "realistic_hard", num_matches: int = 20): |
| opp_dict = { |
| "realistic_hard": ("Realistic Hard Pro", RealisticHardOpponent()), |
| "medium": ("Medium Logic", MediumOpponent()), |
| "easy": ("Easy Logic", EasyOpponent()), |
| "impossible_hard": ("Impossible Hard Wall", ImpossibleHardOpponent()), |
| "random": ("Random Agent", RandomOpponent()) |
| } |
|
|
| if opponent_type not in opp_dict: |
| print(f"[!] Invalid opponent. Choose from: {list(opp_dict.keys())}") |
| return |
|
|
| name, opponent = opp_dict[opponent_type] |
| print("=" * 72) |
| print(f" SIMULATING {num_matches} MATCHES: MRPONG (P1) vs {name.upper()} (P2)") |
| print("=" * 72) |
|
|
| env = StandalonePongEnv() |
| wins, draws, losses = 0, 0, 0 |
| rallies = [] |
| t0 = time.time() |
|
|
| for match_i in range(1, num_matches + 1): |
| obs = env.reset(serve_direction=1 if match_i % 2 == 0 else -1) |
| done = False |
|
|
| while not done: |
| ego_act = self.predict_action(obs, deterministic=True) |
| opp_act = opponent.act(env) |
| obs, done, info = env.step(ego_action=ego_act, opp_action=opp_act) |
|
|
| winner = info.get("winner") |
| rallies.append(env.rally_count) |
|
|
| if winner == "ego": |
| wins += 1 |
| res = "WIN (MrPong Scored)" |
| elif winner == "opponent": |
| losses += 1 |
| res = f"LOSS ({name} Scored)" |
| else: |
| draws += 1 |
| res = "DRAW (Max Rally Limit)" |
|
|
| print(f" Match {match_i:3d}/{num_matches:3d} | Result: {res:<25} | Rally: {env.rally_count:3d} hits") |
|
|
| elapsed = time.time() - t0 |
| print("\n" + "=" * 72) |
| print(" SIMULATION SUMMARY") |
| print("=" * 72) |
| print(f" Opponent : {name}") |
| print(f" Record (W / D / L): {wins} Wins / {draws} Draws / {losses} Losses") |
| print(f" Win Rate : {(wins / num_matches) * 100.0:.1f}%") |
| print(f" Draw Rate : {(draws / num_matches) * 100.0:.1f}%") |
| print(f" Loss Rate : {(losses / num_matches) * 100.0:.1f}%") |
| print(f" Average Rally : {float(np.mean(rallies)):.1f} hits (Max: {max(rallies)} hits)") |
| print(f" Total Time : {elapsed:.2f}s ({num_matches / elapsed:.1f} matches/sec)") |
| print("=" * 72 + "\n") |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="MrPong Hugging Face Public Inference Runner (fromziro/MrPong)") |
| parser.add_argument("--model", type=str, default="fromziro/MrPong", help="Hugging Face repo ID or local path (default: fromziro/MrPong)") |
| parser.add_argument("--mode", type=str, choices=["play", "simulate"], default="play", help="Mode: 'play' to play against AI, 'simulate' for AI vs AI match simulations") |
| parser.add_argument("--difficulty", type=str, choices=["easy", "normal", "hard"], default="normal", help="Difficulty preset for human play mode (default: normal)") |
| parser.add_argument("--points", type=int, default=5, help="Points to win in play mode (default: 5)") |
| parser.add_argument("--opponent", type=str, default="realistic_hard", choices=["realistic_hard", "medium", "easy", "impossible_hard", "random"], help="Opponent type in simulate mode") |
| parser.add_argument("--matches", type=int, default=20, help="Number of matches to simulate (default: 20)") |
|
|
| args = parser.parse_args() |
|
|
| runner = PublicMrPongRunner(model_id_or_path=args.model) |
|
|
| if args.mode == "play": |
| runner.play(points_to_win=args.points, difficulty=args.difficulty) |
| elif args.mode == "simulate": |
| runner.simulate(opponent_type=args.opponent, num_matches=args.matches) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|