Harley-ml commited on
Commit
eea1361
·
verified ·
1 Parent(s): fe8191c

Upload inference.py

Browse files
Files changed (1) hide show
  1. inference.py +137 -83
inference.py CHANGED
@@ -5,7 +5,7 @@
5
  Model Repository: https://huggingface.co/fromziro/MrPong
6
 
7
  Zero-dependency, standalone script for the public to:
8
- 1. Play against MrPong live in the terminal (Flicker-free, 60 FPS continuous physics)
9
  2. Run match simulations against built-in AI baseline opponents
10
 
11
  Quickstart:
@@ -34,20 +34,20 @@ except ImportError:
34
  # Platform-specific real-time non-blocking keyboard input
35
  IS_WINDOWS = sys.platform.startswith("win")
36
  if IS_WINDOWS:
 
 
 
37
  try:
38
- import msvcrt
 
 
39
  HAS_KEYBOARD = True
40
  except ImportError:
41
  HAS_KEYBOARD = False
42
- else:
43
- import select
44
- import tty
45
- import termios
46
- HAS_KEYBOARD = True
47
 
48
 
49
  # =================================================================================================
50
- # STANDALONE PING PONG PHYSICS & ENVIRONMENT (ZERO EXTERNAL PROJECT DEPENDENCIES)
51
  # =================================================================================================
52
 
53
  @dataclass
@@ -71,9 +71,11 @@ class StandalonePongEnv:
71
  def __init__(self, phys: Optional[PhysicsConfig] = None, seed: Optional[int] = None):
72
  self.phys = phys or PhysicsConfig()
73
  self.rng = random.Random(seed)
 
 
74
  self.reset()
75
 
76
- def reset(self, serve_direction: Optional[int] = None) -> np.ndarray:
77
  self.ego_y = self.phys.table_height / 2.0
78
  self.opp_y = self.phys.table_height / 2.0
79
  self.ego_vy = 0.0
@@ -86,8 +88,8 @@ class StandalonePongEnv:
86
  if serve_direction is None:
87
  serve_direction = 1 if self.rng.random() < 0.5 else -1
88
 
89
- serve_angle = self.rng.uniform(-math.pi / 6.0, math.pi / 6.0)
90
- speed = self.phys.ball_speed_initial
91
  self.ball_vx = serve_direction * speed * math.cos(serve_angle)
92
  self.ball_vy = speed * math.sin(serve_angle)
93
  self.rally_count = 0
@@ -117,9 +119,11 @@ class StandalonePongEnv:
117
  self.ego_vy = alpha * self.ego_vy + (1.0 - alpha) * ego_target_v
118
  self.opp_vy = alpha * self.opp_vy + (1.0 - alpha) * opp_target_v
119
 
120
- half_h = self.phys.paddle_height / 2.0
121
- self.ego_y = float(np.clip(self.ego_y + self.ego_vy, half_h, self.phys.table_height - half_h))
122
- self.opp_y = float(np.clip(self.opp_y + self.opp_vy, half_h, self.phys.table_height - half_h))
 
 
123
 
124
  prev_ball_x = self.ball_x
125
  prev_ball_y = self.ball_y
@@ -141,10 +145,10 @@ class StandalonePongEnv:
141
  y_ball_at_impact = prev_ball_y + t * self.ball_vy
142
  y_ego_at_impact = prev_ego_y + t * (self.ego_y - prev_ego_y)
143
 
144
- if abs(y_ball_at_impact - y_ego_at_impact) <= (half_h + r * 0.6):
145
  hit_occurred = True
146
  self.rally_count += 1
147
- offset = float(np.clip((y_ball_at_impact - y_ego_at_impact) / half_h, -1.0, 1.0))
148
  bounce_angle = offset * (math.pi / 3.0)
149
  current_speed = math.hypot(self.ball_vx, self.ball_vy)
150
  new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max)
@@ -163,10 +167,10 @@ class StandalonePongEnv:
163
  y_ball_at_impact = prev_ball_y + t * self.ball_vy
164
  y_opp_at_impact = prev_opp_y + t * (self.opp_y - prev_opp_y)
165
 
166
- if abs(y_ball_at_impact - y_opp_at_impact) <= (half_h + r * 0.6):
167
  hit_occurred = True
168
  self.rally_count += 1
169
- offset = float(np.clip((y_ball_at_impact - y_opp_at_impact) / half_h, -1.0, 1.0))
170
  bounce_angle = offset * (math.pi / 3.0)
171
  current_speed = math.hypot(self.ball_vx, self.ball_vy)
172
  new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max)
@@ -254,7 +258,7 @@ class StandalonePongEnv:
254
  w, h = self.phys.table_width, self.phys.table_height
255
  v_max = self.phys.ball_speed_max
256
  pv_max = self.phys.paddle_speed
257
- half_h = self.phys.paddle_height / 2.0
258
  ego_x = self.phys.paddle_width
259
 
260
  pred_intercept_y = self.calculate_intercept_y(ego_x, self.ball_x, self.ball_y, self.ball_vx, self.ball_vy)
@@ -289,7 +293,7 @@ class StandalonePongEnv:
289
  w, h = self.phys.table_width, self.phys.table_height
290
  v_max = self.phys.ball_speed_max
291
  pv_max = self.phys.paddle_speed
292
- half_h = self.phys.paddle_height / 2.0
293
  opp_x = self.phys.table_width - self.phys.paddle_width
294
 
295
  pred_intercept_y = self.calculate_intercept_y(opp_x, self.ball_x, self.ball_y, self.ball_vx, self.ball_vy)
@@ -408,44 +412,77 @@ class RandomOpponent:
408
 
409
 
410
  # =================================================================================================
411
- # NON-BLOCKING KEYBOARD INPUT
412
  # =================================================================================================
413
 
414
  class KeyboardController:
 
 
 
 
415
  def __init__(self):
416
  self.is_windows = IS_WINDOWS
417
- if not self.is_windows and HAS_KEYBOARD:
418
- self.old_settings = termios.tcgetattr(sys.stdin)
419
- tty.setcbreak(sys.stdin.fileno())
420
-
421
- def get_action(self) -> Optional[int]:
422
- if not HAS_KEYBOARD:
423
- return None
424
-
425
  if self.is_windows:
426
- if msvcrt.kbhit():
427
- ch = msvcrt.getch()
428
- if ch in [b'w', b'W', b'H']: # 'w' or Up Arrow
429
- return 1
430
- elif ch in [b's', b'S', b'P']: # 's' or Down Arrow
431
- return 2
432
- elif ch in [b' ', b'\r', b'\n']:
433
- return 0
434
- elif ch in [b'q', b'Q']:
435
- return -1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  else:
 
 
 
437
  rlist, _, _ = select.select([sys.stdin], [], [], 0)
438
  if rlist:
439
  ch = sys.stdin.read(1)
440
  if ch in ['w', 'W']:
441
- return 1
 
442
  elif ch in ['s', 'S']:
443
- return 2
444
- elif ch in [' ', '\n']:
445
- return 0
446
  elif ch in ['q', 'Q']:
447
  return -1
448
- return None
 
 
 
 
 
 
449
 
450
  def close(self):
451
  if not self.is_windows and HAS_KEYBOARD:
@@ -460,11 +497,9 @@ class KeyboardController:
460
  # =================================================================================================
461
 
462
  def resolve_model_path(path_or_id: Optional[str] = None) -> str:
463
- # 1. If explicit local path exists, use its absolute path
464
  if path_or_id and os.path.exists(path_or_id):
465
  return os.path.abspath(path_or_id)
466
 
467
- # 2. If local ./MrPong repository folder exists locally, prefer it over remote hub
468
  local_candidates = [
469
  os.path.abspath("./MrPong"),
470
  os.path.abspath("C:/Users/harley/MrPong"),
@@ -496,51 +531,71 @@ class PublicMrPongRunner:
496
  return self.model.act(obs_input, deterministic=deterministic)
497
 
498
  # ---------------------------------------------------------------------------------------------
499
- # PLAY INTERACTIVELY IN TERMINAL (FLICKER-FREE 60 FPS CONTINUOUS SUB-FRAME RENDERING)
500
  # ---------------------------------------------------------------------------------------------
501
- def play(self, points_to_win: int = 5, target_fps: int = 40):
502
- env = StandalonePongEnv()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  kbd = KeyboardController()
 
504
  human_score = 0
505
  ai_score = 0
506
  max_rally = 0
507
- dt = 1.0 / target_fps
508
 
509
  print("=" * 64)
510
- print(" 🏓 PLAY AGAINST MRPONG (FLICKER-FREE TERMINAL MODE)")
511
  print("=" * 64)
512
  print(" Controls:")
513
- print(" [W] / [Up Arrow] : Move Paddle UP")
514
- print(" [S] / [Down Arrow] : Move Paddle DOWN")
515
- print(" [Space] : STAY still")
516
- print(" [Q] : Quit match")
517
- print(f"\n First to {points_to_win} points wins!")
518
- input("\n Press [ENTER] to serve and start match...")
519
-
520
- # Hide terminal cursor for crisp rendering
521
  sys.stdout.write("\033[?25l")
522
  sys.stdout.flush()
523
 
524
- human_act = 0
525
  serve_dir = 1
526
  ai_act = 0
527
  substep_count = 0
528
 
529
  try:
530
  while human_score < points_to_win and ai_score < points_to_win:
531
- obs = env.reset(serve_direction=serve_dir)
 
 
 
 
 
532
  done = False
533
 
534
  while not done:
535
  t_start = time.perf_counter()
536
 
537
- # 1. Read non-blocking human input on every physics sub-step
538
- key_act = kbd.get_action()
539
- if key_act == -1:
540
  print("\n[!] Match aborted by player.")
541
  return
542
- elif key_act is not None:
543
- human_act = key_act
544
 
545
  # 2. Query AI policy every frame_skip sub-steps
546
  if substep_count % env.phys.frame_skip == 0:
@@ -549,7 +604,7 @@ class PublicMrPongRunner:
549
 
550
  substep_count += 1
551
 
552
- # 3. Advance exactly 1 continuous physics sub-step
553
  done, info = env.physics_substep(ego_action=human_act, opp_action=ai_act)
554
 
555
  if env.rally_count > max_rally:
@@ -558,28 +613,27 @@ class PublicMrPongRunner:
558
  # 4. Render smooth flicker-free frame
559
  self._render_terminal_court(env, human_score, ai_score, human_act, ai_act, points_to_win)
560
 
561
- # 5. Maintain rock-solid frame timing
562
  t_elapsed = time.perf_counter() - t_start
563
- if t_elapsed < dt:
564
- time.sleep(dt - t_elapsed)
565
 
566
  # Point completed
567
  winner = info.get("winner")
568
  if winner == "ego":
569
  human_score += 1
570
  serve_dir = 1
571
- banner = ">>> YOU SCORED! <<<"
572
  elif winner == "opponent":
573
  ai_score += 1
574
  serve_dir = -1
575
- banner = ">>> MRPONG SCORED! <<<"
576
  else:
577
  banner = ">>> RALLY DRAW <<<"
578
 
579
- self._render_terminal_court(env, human_score, ai_score, human_act, ai_act, points_to_win, banner=banner)
580
- time.sleep(1.0)
581
 
582
- # Match Over
583
  sys.stdout.write("\033[?25h\033[H\033[J")
584
  print("\n" + "=" * 64)
585
  if human_score >= points_to_win:
@@ -594,25 +648,26 @@ class PublicMrPongRunner:
594
  kbd.close()
595
 
596
  def _render_terminal_court(self, env: StandalonePongEnv, s1: int, s2: int, a1: int, a2: int, target: int, banner: str = ""):
597
- """Atomic flicker-free frame buffer rendering (overwrites cursor home without clear)."""
598
  cols, rows = 60, 18
599
  bx = int(np.clip((env.ball_x / env.phys.table_width) * (cols - 1), 0, cols - 1))
600
  by = int(np.clip((env.ball_y / env.phys.table_height) * (rows - 1), 0, rows - 1))
601
  p1_y = int(np.clip((env.ego_y / env.phys.table_height) * (rows - 1), 0, rows - 1))
602
  p2_y = int(np.clip((env.opp_y / env.phys.table_height) * (rows - 1), 0, rows - 1))
603
- ph = max(1, int((env.phys.paddle_height / env.phys.table_height) * (rows - 1) / 2))
 
 
604
 
605
  act_names = ["STAY", " UP ", "DOWN"]
606
  buf = []
607
 
608
- # Cursor Home only (avoids screen erase strobe)
609
  buf.append("\033[H")
610
  buf.append("+" + "-" * (cols + 2) + "+\n")
611
  buf.append(f"| YOU [P1]: {s1}/{target} ({act_names[a1]})" + " " * (cols - 41) + f"MRPONG [AI]: {s2}/{target} ({act_names[a2]}) |\n")
612
  buf.append("+" + "-" * (cols + 2) + "+\n")
613
 
614
  for r in range(rows):
615
- line = ["|" if abs(r - p1_y) <= ph else " "]
616
  for c in range(cols):
617
  if r == by and c == bx:
618
  line.append("O")
@@ -620,7 +675,7 @@ class PublicMrPongRunner:
620
  line.append(":")
621
  else:
622
  line.append(" ")
623
- line.append("|" if abs(r - p2_y) <= ph else " ")
624
  buf.append("|" + "".join(line) + "|\n")
625
 
626
  buf.append("+" + "-" * (cols + 2) + "+\n")
@@ -631,7 +686,6 @@ class PublicMrPongRunner:
631
  buf.append(f"| Rally: {env.rally_count:2d} hits | Ball Speed: {speed:4.1f} px/f" + " " * (cols - 33) + "|\n")
632
  buf.append("+" + "-" * (cols + 2) + "+\n")
633
 
634
- # Single atomic write
635
  sys.stdout.write("".join(buf))
636
  sys.stdout.flush()
637
 
@@ -703,8 +757,8 @@ def main():
703
  parser = argparse.ArgumentParser(description="MrPong Hugging Face Public Inference Runner (fromziro/MrPong)")
704
  parser.add_argument("--model", type=str, default="fromziro/MrPong", help="Hugging Face repo ID or local path (default: fromziro/MrPong)")
705
  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")
 
706
  parser.add_argument("--points", type=int, default=5, help="Points to win in play mode (default: 5)")
707
- parser.add_argument("--fps", type=int, default=40, help="Target frame rate for interactive gameplay (default: 40)")
708
  parser.add_argument("--opponent", type=str, default="realistic_hard", choices=["realistic_hard", "medium", "easy", "impossible_hard", "random"], help="Opponent type in simulate mode")
709
  parser.add_argument("--matches", type=int, default=20, help="Number of matches to simulate (default: 20)")
710
 
@@ -713,7 +767,7 @@ def main():
713
  runner = PublicMrPongRunner(model_id_or_path=args.model)
714
 
715
  if args.mode == "play":
716
- runner.play(points_to_win=args.points, target_fps=args.fps)
717
  elif args.mode == "simulate":
718
  runner.simulate(opponent_type=args.opponent, num_matches=args.matches)
719
 
 
5
  Model Repository: https://huggingface.co/fromziro/MrPong
6
 
7
  Zero-dependency, standalone script for the public to:
8
+ 1. Play against MrPong live in the terminal (Real-time hold-to-move keyboard control)
9
  2. Run match simulations against built-in AI baseline opponents
10
 
11
  Quickstart:
 
34
  # Platform-specific real-time non-blocking keyboard input
35
  IS_WINDOWS = sys.platform.startswith("win")
36
  if IS_WINDOWS:
37
+ import ctypes
38
+ HAS_KEYBOARD = True
39
+ else:
40
  try:
41
+ import select
42
+ import tty
43
+ import termios
44
  HAS_KEYBOARD = True
45
  except ImportError:
46
  HAS_KEYBOARD = False
 
 
 
 
 
47
 
48
 
49
  # =================================================================================================
50
+ # STANDALONE PING PONG PHYSICS & ENVIRONMENT
51
  # =================================================================================================
52
 
53
  @dataclass
 
71
  def __init__(self, phys: Optional[PhysicsConfig] = None, seed: Optional[int] = None):
72
  self.phys = phys or PhysicsConfig()
73
  self.rng = random.Random(seed)
74
+ self.ego_paddle_h = self.phys.paddle_height
75
+ self.opp_paddle_h = self.phys.paddle_height
76
  self.reset()
77
 
78
+ def reset(self, serve_direction: Optional[int] = None, initial_speed: Optional[float] = None) -> np.ndarray:
79
  self.ego_y = self.phys.table_height / 2.0
80
  self.opp_y = self.phys.table_height / 2.0
81
  self.ego_vy = 0.0
 
88
  if serve_direction is None:
89
  serve_direction = 1 if self.rng.random() < 0.5 else -1
90
 
91
+ serve_angle = self.rng.uniform(-math.pi / 7.0, math.pi / 7.0)
92
+ speed = initial_speed or self.phys.ball_speed_initial
93
  self.ball_vx = serve_direction * speed * math.cos(serve_angle)
94
  self.ball_vy = speed * math.sin(serve_angle)
95
  self.rally_count = 0
 
119
  self.ego_vy = alpha * self.ego_vy + (1.0 - alpha) * ego_target_v
120
  self.opp_vy = alpha * self.opp_vy + (1.0 - alpha) * opp_target_v
121
 
122
+ ego_half_h = self.ego_paddle_h / 2.0
123
+ opp_half_h = self.opp_paddle_h / 2.0
124
+
125
+ self.ego_y = float(np.clip(self.ego_y + self.ego_vy, ego_half_h, self.phys.table_height - ego_half_h))
126
+ self.opp_y = float(np.clip(self.opp_y + self.opp_vy, opp_half_h, self.phys.table_height - opp_half_h))
127
 
128
  prev_ball_x = self.ball_x
129
  prev_ball_y = self.ball_y
 
145
  y_ball_at_impact = prev_ball_y + t * self.ball_vy
146
  y_ego_at_impact = prev_ego_y + t * (self.ego_y - prev_ego_y)
147
 
148
+ if abs(y_ball_at_impact - y_ego_at_impact) <= (ego_half_h + r * 0.6):
149
  hit_occurred = True
150
  self.rally_count += 1
151
+ offset = float(np.clip((y_ball_at_impact - y_ego_at_impact) / ego_half_h, -1.0, 1.0))
152
  bounce_angle = offset * (math.pi / 3.0)
153
  current_speed = math.hypot(self.ball_vx, self.ball_vy)
154
  new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max)
 
167
  y_ball_at_impact = prev_ball_y + t * self.ball_vy
168
  y_opp_at_impact = prev_opp_y + t * (self.opp_y - prev_opp_y)
169
 
170
+ if abs(y_ball_at_impact - y_opp_at_impact) <= (opp_half_h + r * 0.6):
171
  hit_occurred = True
172
  self.rally_count += 1
173
+ offset = float(np.clip((y_ball_at_impact - y_opp_at_impact) / opp_half_h, -1.0, 1.0))
174
  bounce_angle = offset * (math.pi / 3.0)
175
  current_speed = math.hypot(self.ball_vx, self.ball_vy)
176
  new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max)
 
258
  w, h = self.phys.table_width, self.phys.table_height
259
  v_max = self.phys.ball_speed_max
260
  pv_max = self.phys.paddle_speed
261
+ half_h = self.ego_paddle_h / 2.0
262
  ego_x = self.phys.paddle_width
263
 
264
  pred_intercept_y = self.calculate_intercept_y(ego_x, self.ball_x, self.ball_y, self.ball_vx, self.ball_vy)
 
293
  w, h = self.phys.table_width, self.phys.table_height
294
  v_max = self.phys.ball_speed_max
295
  pv_max = self.phys.paddle_speed
296
+ half_h = self.opp_paddle_h / 2.0
297
  opp_x = self.phys.table_width - self.phys.paddle_width
298
 
299
  pred_intercept_y = self.calculate_intercept_y(opp_x, self.ball_x, self.ball_y, self.ball_vx, self.ball_vy)
 
412
 
413
 
414
  # =================================================================================================
415
+ # REAL-TIME HOLD-TO-MOVE KEYBOARD CONTROLLER
416
  # =================================================================================================
417
 
418
  class KeyboardController:
419
+ """
420
+ Direct hardware key state listener.
421
+ Holding W/Up moves UP. Holding S/Down moves DOWN. Releasing stops (STAY).
422
+ """
423
  def __init__(self):
424
  self.is_windows = IS_WINDOWS
 
 
 
 
 
 
 
 
425
  if self.is_windows:
426
+ self.user32 = ctypes.windll.user32
427
+ # Virtual Key Codes
428
+ self.VK_W = 0x57
429
+ self.VK_S = 0x53
430
+ self.VK_UP = 0x26
431
+ self.VK_DOWN = 0x28
432
+ self.VK_Q = 0x51
433
+ self.VK_ESCAPE = 0x1B
434
+ else:
435
+ self.decay_frames = 0
436
+ self.current_act = 0
437
+ if HAS_KEYBOARD:
438
+ self.old_settings = termios.tcgetattr(sys.stdin)
439
+ tty.setcbreak(sys.stdin.fileno())
440
+
441
+ def get_action(self) -> int:
442
+ """
443
+ Returns:
444
+ 1: UP (while W / Up Arrow is held)
445
+ 2: DOWN (while S / Down Arrow is held)
446
+ 0: STAY (when released)
447
+ -1: QUIT (when Q / ESC is pressed)
448
+ """
449
+ if self.is_windows:
450
+ # Check Quit
451
+ if (self.user32.GetAsyncKeyState(self.VK_Q) & 0x8000) or (self.user32.GetAsyncKeyState(self.VK_ESCAPE) & 0x8000):
452
+ return -1
453
+
454
+ # Direct hardware physical key state check (0 latency)
455
+ w_held = bool((self.user32.GetAsyncKeyState(self.VK_W) & 0x8000) or (self.user32.GetAsyncKeyState(self.VK_UP) & 0x8000))
456
+ s_held = bool((self.user32.GetAsyncKeyState(self.VK_S) & 0x8000) or (self.user32.GetAsyncKeyState(self.VK_DOWN) & 0x8000))
457
+
458
+ if w_held and not s_held:
459
+ return 1
460
+ elif s_held and not w_held:
461
+ return 2
462
+ else:
463
+ return 0
464
  else:
465
+ # Unix non-blocking input with key-release decay
466
+ if not HAS_KEYBOARD:
467
+ return 0
468
  rlist, _, _ = select.select([sys.stdin], [], [], 0)
469
  if rlist:
470
  ch = sys.stdin.read(1)
471
  if ch in ['w', 'W']:
472
+ self.current_act = 1
473
+ self.decay_frames = 5
474
  elif ch in ['s', 'S']:
475
+ self.current_act = 2
476
+ self.decay_frames = 5
 
477
  elif ch in ['q', 'Q']:
478
  return -1
479
+
480
+ if self.decay_frames > 0:
481
+ self.decay_frames -= 1
482
+ return self.current_act
483
+ else:
484
+ self.current_act = 0
485
+ return 0
486
 
487
  def close(self):
488
  if not self.is_windows and HAS_KEYBOARD:
 
497
  # =================================================================================================
498
 
499
  def resolve_model_path(path_or_id: Optional[str] = None) -> str:
 
500
  if path_or_id and os.path.exists(path_or_id):
501
  return os.path.abspath(path_or_id)
502
 
 
503
  local_candidates = [
504
  os.path.abspath("./MrPong"),
505
  os.path.abspath("C:/Users/harley/MrPong"),
 
531
  return self.model.act(obs_input, deterministic=deterministic)
532
 
533
  # ---------------------------------------------------------------------------------------------
534
+ # PLAY INTERACTIVELY IN TERMINAL (HOLD TO MOVE, RELEASE TO STAY)
535
  # ---------------------------------------------------------------------------------------------
536
+ def play(self, points_to_win: int = 5, difficulty: str = "normal"):
537
+ phys = PhysicsConfig()
538
+
539
+ if difficulty == "easy":
540
+ phys.paddle_speed = 9.0
541
+ initial_ball_speed = 3.5
542
+ ego_paddle_h = 110.0
543
+ frame_delay = 0.028
544
+ elif difficulty == "hard":
545
+ phys.paddle_speed = 8.0
546
+ initial_ball_speed = 6.0
547
+ ego_paddle_h = 80.0
548
+ frame_delay = 0.022
549
+ else: # normal
550
+ phys.paddle_speed = 8.5
551
+ initial_ball_speed = 4.2
552
+ ego_paddle_h = 95.0
553
+ frame_delay = 0.025
554
+
555
+ env = StandalonePongEnv(phys)
556
+ env.ego_paddle_h = ego_paddle_h
557
  kbd = KeyboardController()
558
+
559
  human_score = 0
560
  ai_score = 0
561
  max_rally = 0
 
562
 
563
  print("=" * 64)
564
+ print(f" 🏓 PLAY AGAINST MRPONG (ARCADE MODE - {difficulty.upper()})")
565
  print("=" * 64)
566
  print(" Controls:")
567
+ print(" Hold [W] / [Up Arrow] : Move UP")
568
+ print(" Hold [S] / [Down Arrow] : Move DOWN")
569
+ print(" Release key : STAY still")
570
+ print(" [Q] / [Esc] : Quit match")
571
+ print(f"\n First player to {points_to_win} points wins!")
572
+ input("\n Press [ENTER] to start match...")
573
+
 
574
  sys.stdout.write("\033[?25l")
575
  sys.stdout.flush()
576
 
 
577
  serve_dir = 1
578
  ai_act = 0
579
  substep_count = 0
580
 
581
  try:
582
  while human_score < points_to_win and ai_score < points_to_win:
583
+ obs = env.reset(serve_direction=serve_dir, initial_speed=initial_ball_speed)
584
+
585
+ for cd in [3, 2, 1]:
586
+ self._render_terminal_court(env, human_score, ai_score, 0, ai_act, points_to_win, banner=f"GET READY: SERVING IN {cd}...")
587
+ time.sleep(0.6)
588
+
589
  done = False
590
 
591
  while not done:
592
  t_start = time.perf_counter()
593
 
594
+ # 1. Real-time physical key check (Hold = Move, Release = Stay)
595
+ human_act = kbd.get_action()
596
+ if human_act == -1:
597
  print("\n[!] Match aborted by player.")
598
  return
 
 
599
 
600
  # 2. Query AI policy every frame_skip sub-steps
601
  if substep_count % env.phys.frame_skip == 0:
 
604
 
605
  substep_count += 1
606
 
607
+ # 3. Advance continuous physics sub-step
608
  done, info = env.physics_substep(ego_action=human_act, opp_action=ai_act)
609
 
610
  if env.rally_count > max_rally:
 
613
  # 4. Render smooth flicker-free frame
614
  self._render_terminal_court(env, human_score, ai_score, human_act, ai_act, points_to_win)
615
 
616
+ # 5. Precise frame timing
617
  t_elapsed = time.perf_counter() - t_start
618
+ if t_elapsed < frame_delay:
619
+ time.sleep(frame_delay - t_elapsed)
620
 
621
  # Point completed
622
  winner = info.get("winner")
623
  if winner == "ego":
624
  human_score += 1
625
  serve_dir = 1
626
+ banner = ">>> POINT TO YOU! <<<"
627
  elif winner == "opponent":
628
  ai_score += 1
629
  serve_dir = -1
630
+ banner = ">>> POINT TO MRPONG! <<<"
631
  else:
632
  banner = ">>> RALLY DRAW <<<"
633
 
634
+ self._render_terminal_court(env, human_score, ai_score, 0, ai_act, points_to_win, banner=banner)
635
+ time.sleep(1.2)
636
 
 
637
  sys.stdout.write("\033[?25h\033[H\033[J")
638
  print("\n" + "=" * 64)
639
  if human_score >= points_to_win:
 
648
  kbd.close()
649
 
650
  def _render_terminal_court(self, env: StandalonePongEnv, s1: int, s2: int, a1: int, a2: int, target: int, banner: str = ""):
651
+ """Atomic flicker-free frame buffer rendering."""
652
  cols, rows = 60, 18
653
  bx = int(np.clip((env.ball_x / env.phys.table_width) * (cols - 1), 0, cols - 1))
654
  by = int(np.clip((env.ball_y / env.phys.table_height) * (rows - 1), 0, rows - 1))
655
  p1_y = int(np.clip((env.ego_y / env.phys.table_height) * (rows - 1), 0, rows - 1))
656
  p2_y = int(np.clip((env.opp_y / env.phys.table_height) * (rows - 1), 0, rows - 1))
657
+
658
+ p1_ph = max(1, int((env.ego_paddle_h / env.phys.table_height) * (rows - 1) / 2))
659
+ p2_ph = max(1, int((env.opp_paddle_h / env.phys.table_height) * (rows - 1) / 2))
660
 
661
  act_names = ["STAY", " UP ", "DOWN"]
662
  buf = []
663
 
 
664
  buf.append("\033[H")
665
  buf.append("+" + "-" * (cols + 2) + "+\n")
666
  buf.append(f"| YOU [P1]: {s1}/{target} ({act_names[a1]})" + " " * (cols - 41) + f"MRPONG [AI]: {s2}/{target} ({act_names[a2]}) |\n")
667
  buf.append("+" + "-" * (cols + 2) + "+\n")
668
 
669
  for r in range(rows):
670
+ line = ["#" if abs(r - p1_y) <= p1_ph else " "]
671
  for c in range(cols):
672
  if r == by and c == bx:
673
  line.append("O")
 
675
  line.append(":")
676
  else:
677
  line.append(" ")
678
+ line.append("|" if abs(r - p2_y) <= p2_ph else " ")
679
  buf.append("|" + "".join(line) + "|\n")
680
 
681
  buf.append("+" + "-" * (cols + 2) + "+\n")
 
686
  buf.append(f"| Rally: {env.rally_count:2d} hits | Ball Speed: {speed:4.1f} px/f" + " " * (cols - 33) + "|\n")
687
  buf.append("+" + "-" * (cols + 2) + "+\n")
688
 
 
689
  sys.stdout.write("".join(buf))
690
  sys.stdout.flush()
691
 
 
757
  parser = argparse.ArgumentParser(description="MrPong Hugging Face Public Inference Runner (fromziro/MrPong)")
758
  parser.add_argument("--model", type=str, default="fromziro/MrPong", help="Hugging Face repo ID or local path (default: fromziro/MrPong)")
759
  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")
760
+ parser.add_argument("--difficulty", type=str, choices=["easy", "normal", "hard"], default="normal", help="Difficulty preset for human play mode (default: normal)")
761
  parser.add_argument("--points", type=int, default=5, help="Points to win in play mode (default: 5)")
 
762
  parser.add_argument("--opponent", type=str, default="realistic_hard", choices=["realistic_hard", "medium", "easy", "impossible_hard", "random"], help="Opponent type in simulate mode")
763
  parser.add_argument("--matches", type=int, default=20, help="Number of matches to simulate (default: 20)")
764
 
 
767
  runner = PublicMrPongRunner(model_id_or_path=args.model)
768
 
769
  if args.mode == "play":
770
+ runner.play(points_to_win=args.points, difficulty=args.difficulty)
771
  elif args.mode == "simulate":
772
  runner.simulate(opponent_type=args.opponent, num_matches=args.matches)
773