Spaces:
Running
Running
Mr. Pong interactive demo: browser-side play + server-side match video & benchmark
Browse files- README.md +36 -7
- app.py +394 -0
- game_template.html +418 -0
- pong_engine.py +403 -0
- requirements.txt +6 -0
README.md
CHANGED
|
@@ -1,13 +1,42 @@
|
|
| 1 |
---
|
| 2 |
-
title: Mr Pong
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.24.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Mr. Pong
|
| 3 |
+
emoji: 🏓
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.24.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
short_description: Play table tennis against a PPO agent
|
| 10 |
+
python_version: "3.12"
|
| 11 |
+
startup_duration_timeout: 30m
|
| 12 |
+
models:
|
| 13 |
+
- fromziro/MrPong
|
| 14 |
+
tags:
|
| 15 |
+
- reinforcement-learning
|
| 16 |
+
- ppo
|
| 17 |
+
- game
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# 🏓 Mr. Pong
|
| 21 |
+
|
| 22 |
+
An interactive demo for [**fromziro/MrPong**](https://huggingface.co/fromziro/MrPong) —
|
| 23 |
+
a 28,484-parameter actor–critic MLP trained with PPO and self-play for 10M steps to
|
| 24 |
+
play 2D table tennis.
|
| 25 |
+
|
| 26 |
+
Three tabs:
|
| 27 |
+
|
| 28 |
+
- **Play Mr. Pong** — the policy is exported from the checkpoint and evaluated in your
|
| 29 |
+
browser (plain JavaScript, ~26K MACs per decision), so the agent's paddle reacts at
|
| 30 |
+
the same 40 sub-steps/second the physics runs at, with no network latency. The
|
| 31 |
+
critic's value estimate `V(s)` is shown live.
|
| 32 |
+
- **Watch a match** — the real PyTorch checkpoint plays a full match against one of the
|
| 33 |
+
scripted baselines from the model card, rendered to video server-side.
|
| 34 |
+
- **Benchmark** — replays the model card's evaluation protocol over N matches.
|
| 35 |
+
|
| 36 |
+
The physics environment (`pong_engine.py`) and the baseline opponents are a 1:1 port of
|
| 37 |
+
the author's reference `inference.py`, so behaviour matches the reported numbers.
|
| 38 |
+
|
| 39 |
+
Runs entirely on CPU: a policy forward pass is a 12→160→160→3 MLP.
|
| 40 |
+
|
| 41 |
+
Model, environment and baselines by [FromZero](https://huggingface.co/fromziro),
|
| 42 |
+
Apache-2.0.
|
app.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
🏓 Mr. Pong — an interactive demo for fromziro/MrPong.
|
| 3 |
+
|
| 4 |
+
Two ways to meet the agent:
|
| 5 |
+
1. Play against it live in the browser. The 28K-parameter policy is exported
|
| 6 |
+
from the checkpoint and evaluated client-side at 40 physics steps/second,
|
| 7 |
+
so the paddle reacts with zero network latency.
|
| 8 |
+
2. Watch / benchmark the real PyTorch checkpoint playing full matches against
|
| 9 |
+
the baseline opponents from the model card, rendered to video on the server.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import base64
|
| 13 |
+
import html as html_lib
|
| 14 |
+
import math
|
| 15 |
+
import os
|
| 16 |
+
import random
|
| 17 |
+
import tempfile
|
| 18 |
+
import time
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import gradio as gr
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 25 |
+
from transformers import AutoConfig, AutoModel
|
| 26 |
+
|
| 27 |
+
from pong_engine import (
|
| 28 |
+
OPPONENTS,
|
| 29 |
+
PhysicsConfig,
|
| 30 |
+
StandalonePongEnv,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
MODEL_ID = "fromziro/MrPong"
|
| 34 |
+
HERE = Path(__file__).parent
|
| 35 |
+
|
| 36 |
+
# ----------------------------------------------------------------------------
|
| 37 |
+
# Model (28,484 params — pure CPU, a forward pass costs microseconds)
|
| 38 |
+
# ----------------------------------------------------------------------------
|
| 39 |
+
config = AutoConfig.from_pretrained(MODEL_ID, trust_remote_code=True)
|
| 40 |
+
model = AutoModel.from_pretrained(MODEL_ID, trust_remote_code=True)
|
| 41 |
+
model.eval()
|
| 42 |
+
OBS_DIM = int(getattr(config, "obs_dim", 12))
|
| 43 |
+
N_PARAMS = sum(p.numel() for p in model.parameters())
|
| 44 |
+
print(f"[OK] {MODEL_ID} loaded — {N_PARAMS:,} params, obs_dim={OBS_DIM}, "
|
| 45 |
+
f"hidden_dims={config.hidden_dims}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@torch.no_grad()
|
| 49 |
+
def policy_action(obs: np.ndarray, deterministic: bool = True) -> int:
|
| 50 |
+
"""Greedy action from the Mr. Pong policy (0 stay, 1 up, 2 down)."""
|
| 51 |
+
x = obs[:OBS_DIM] if len(obs) >= OBS_DIM else np.pad(obs, (0, OBS_DIM - len(obs)))
|
| 52 |
+
return int(model.act(x, deterministic=deterministic))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ----------------------------------------------------------------------------
|
| 56 |
+
# Export the checkpoint for the in-browser copy of the policy
|
| 57 |
+
# ----------------------------------------------------------------------------
|
| 58 |
+
def _export_weights_b64() -> str:
|
| 59 |
+
sd = model.state_dict()
|
| 60 |
+
order = ["trunk.0.weight", "trunk.0.bias", "trunk.2.weight", "trunk.2.bias",
|
| 61 |
+
"actor.weight", "actor.bias", "critic.weight", "critic.bias"]
|
| 62 |
+
flat = np.concatenate([sd[k].detach().cpu().numpy().astype("<f4").ravel()
|
| 63 |
+
for k in order])
|
| 64 |
+
assert flat.size == N_PARAMS, (flat.size, N_PARAMS)
|
| 65 |
+
return base64.b64encode(flat.tobytes()).decode("ascii")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
GAME_HTML = (HERE / "game_template.html").read_text().replace(
|
| 69 |
+
"__WEIGHTS_B64__", _export_weights_b64()
|
| 70 |
+
)
|
| 71 |
+
GAME_IFRAME = (
|
| 72 |
+
'<iframe title="Play Mr. Pong" srcdoc="{}" '
|
| 73 |
+
'style="width:100%;height:700px;border:0;border-radius:12px;overflow:hidden" '
|
| 74 |
+
'scrolling="no"></iframe>'
|
| 75 |
+
).format(html_lib.escape(GAME_HTML, quote=True))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ----------------------------------------------------------------------------
|
| 79 |
+
# Server-side match rendering (real PyTorch checkpoint)
|
| 80 |
+
# ----------------------------------------------------------------------------
|
| 81 |
+
SC = 0.8 # render scale: 800x500 table -> 640x400 video
|
| 82 |
+
VW, VH = int(800 * SC), int(500 * SC)
|
| 83 |
+
FPS = 40 # one frame per physics sub-step == real time
|
| 84 |
+
MAX_FRAMES = 1100 # hard cap on video length (~27 s)
|
| 85 |
+
POINT_SUBSTEP_CAP = 420 # a single point is called a draw after this
|
| 86 |
+
MAX_POINTS = 12 # safety net against endless draw sequences
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _font(size: int):
|
| 90 |
+
for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 91 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
|
| 92 |
+
if os.path.exists(p):
|
| 93 |
+
return ImageFont.truetype(p, size)
|
| 94 |
+
try:
|
| 95 |
+
return ImageFont.load_default(size=size)
|
| 96 |
+
except TypeError:
|
| 97 |
+
return ImageFont.load_default()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
F_BIG, F_MED, F_SMALL = _font(34), _font(15), _font(13)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _render_frame(env, score_a, score_b, opp_name, trail, banner=""):
|
| 104 |
+
img = Image.new("RGB", (VW, VH), (15, 22, 38))
|
| 105 |
+
d = ImageDraw.Draw(img)
|
| 106 |
+
|
| 107 |
+
for y in range(0, VH, 26):
|
| 108 |
+
d.line([(VW // 2, y), (VW // 2, min(VH, y + 12))], fill=(38, 48, 70), width=2)
|
| 109 |
+
d.rectangle([0, 0, VW - 1, VH - 1], outline=(28, 36, 54), width=2)
|
| 110 |
+
|
| 111 |
+
for i, (tx, ty) in enumerate(trail):
|
| 112 |
+
a = (i + 1) / (len(trail) + 1)
|
| 113 |
+
c = int(60 + 150 * a)
|
| 114 |
+
r = 8 * SC * (0.35 + 0.6 * a)
|
| 115 |
+
d.ellipse([tx * SC - r, ty * SC - r, tx * SC + r, ty * SC + r], fill=(c, c, c))
|
| 116 |
+
|
| 117 |
+
pw = 14 * SC
|
| 118 |
+
eh = env.ego_paddle_h * SC / 2
|
| 119 |
+
oh = env.opp_paddle_h * SC / 2
|
| 120 |
+
d.rounded_rectangle([2, env.ego_y * SC - eh, 2 + pw, env.ego_y * SC + eh],
|
| 121 |
+
radius=5, fill=(79, 140, 255)) # Mr. Pong (blue)
|
| 122 |
+
d.rounded_rectangle([VW - pw - 2, env.opp_y * SC - oh, VW - 2, env.opp_y * SC + oh],
|
| 123 |
+
radius=5, fill=(249, 115, 22)) # baseline (orange)
|
| 124 |
+
|
| 125 |
+
r = 8 * SC
|
| 126 |
+
d.ellipse([env.ball_x * SC - r, env.ball_y * SC - r,
|
| 127 |
+
env.ball_x * SC + r, env.ball_y * SC + r], fill=(255, 255, 255))
|
| 128 |
+
|
| 129 |
+
d.text((VW // 2 - 46, 16), str(score_a), font=F_BIG, fill=(79, 140, 255), anchor="ma")
|
| 130 |
+
d.text((VW // 2 + 46, 16), str(score_b), font=F_BIG, fill=(249, 115, 22), anchor="ma")
|
| 131 |
+
d.text((20, 20), "MR. PONG", font=F_MED, fill=(120, 150, 200))
|
| 132 |
+
d.text((VW - 20, 20), opp_name.upper(), font=F_MED, fill=(190, 130, 80), anchor="ra")
|
| 133 |
+
|
| 134 |
+
speed = math.hypot(env.ball_vx, env.ball_vy)
|
| 135 |
+
d.text((20, VH - 26),
|
| 136 |
+
f"rally {env.rally_count} hits ball {speed:4.1f} px/f", font=F_SMALL,
|
| 137 |
+
fill=(120, 135, 160))
|
| 138 |
+
|
| 139 |
+
if banner:
|
| 140 |
+
d.rectangle([0, VH // 2 - 34, VW, VH // 2 + 34], fill=(11, 15, 25))
|
| 141 |
+
d.text((VW // 2, VH // 2), banner, font=F_BIG, fill=(249, 115, 22), anchor="mm")
|
| 142 |
+
return np.asarray(img)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _play_point(env, opponent, frames, opp_name, score_a, score_b, serve_dir, record):
|
| 146 |
+
"""Run one point sub-step by sub-step; returns the winner string."""
|
| 147 |
+
env.reset(serve_direction=serve_dir)
|
| 148 |
+
trail = []
|
| 149 |
+
ai_act = 0
|
| 150 |
+
opp_act = 0
|
| 151 |
+
for i in range(POINT_SUBSTEP_CAP):
|
| 152 |
+
# Both policies are re-queried every `frame_skip` sub-steps, exactly as
|
| 153 |
+
# StandalonePongEnv.step() does in the author's simulate() loop.
|
| 154 |
+
if i % env.phys.frame_skip == 0:
|
| 155 |
+
ai_act = policy_action(env.get_ego_observation())
|
| 156 |
+
opp_act = opponent.act(env)
|
| 157 |
+
trail.append((env.ball_x, env.ball_y))
|
| 158 |
+
if len(trail) > 8:
|
| 159 |
+
trail.pop(0)
|
| 160 |
+
done, info = env.physics_substep(ego_action=ai_act, opp_action=opp_act)
|
| 161 |
+
if record and len(frames) < MAX_FRAMES:
|
| 162 |
+
frames.append(_render_frame(env, score_a, score_b, opp_name, trail))
|
| 163 |
+
if done:
|
| 164 |
+
return info.get("winner")
|
| 165 |
+
return "draw"
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def watch_match(opponent_name: str, points_to_win: int = 3,
|
| 169 |
+
randomize_seed: bool = True, seed: int = 0):
|
| 170 |
+
"""Simulate and render a Mr. Pong match against a baseline opponent.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
opponent_name: which scripted baseline to face (from the model card).
|
| 174 |
+
points_to_win: number of points needed to take the match.
|
| 175 |
+
randomize_seed: draw a fresh random seed for this match.
|
| 176 |
+
seed: RNG seed used when randomize_seed is off.
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
An MP4 of the match, a markdown scoreline, and the seed actually used.
|
| 180 |
+
"""
|
| 181 |
+
import imageio.v2 as imageio
|
| 182 |
+
|
| 183 |
+
if randomize_seed:
|
| 184 |
+
seed = random.randint(0, 2**31 - 1)
|
| 185 |
+
seed = int(seed)
|
| 186 |
+
rng = random.Random(seed)
|
| 187 |
+
random.seed(seed)
|
| 188 |
+
np.random.seed(seed % (2**32))
|
| 189 |
+
|
| 190 |
+
points_to_win = int(points_to_win)
|
| 191 |
+
opp_cls = OPPONENTS[opponent_name]
|
| 192 |
+
opponent = opp_cls(rng=rng)
|
| 193 |
+
|
| 194 |
+
env = StandalonePongEnv(PhysicsConfig(), seed=seed)
|
| 195 |
+
frames = []
|
| 196 |
+
score_a = score_b = 0
|
| 197 |
+
serve_dir = 1
|
| 198 |
+
rallies = []
|
| 199 |
+
t0 = time.perf_counter()
|
| 200 |
+
|
| 201 |
+
while (score_a < points_to_win and score_b < points_to_win
|
| 202 |
+
and len(rallies) < MAX_POINTS):
|
| 203 |
+
winner = _play_point(env, opponent, frames, opponent_name,
|
| 204 |
+
score_a, score_b, serve_dir,
|
| 205 |
+
record=len(frames) < MAX_FRAMES)
|
| 206 |
+
rallies.append(env.rally_count)
|
| 207 |
+
if winner == "ego":
|
| 208 |
+
score_a += 1
|
| 209 |
+
serve_dir = 1
|
| 210 |
+
elif winner == "opponent":
|
| 211 |
+
score_b += 1
|
| 212 |
+
serve_dir = -1
|
| 213 |
+
else:
|
| 214 |
+
serve_dir = -serve_dir
|
| 215 |
+
if len(frames) >= MAX_FRAMES:
|
| 216 |
+
break
|
| 217 |
+
|
| 218 |
+
banner = ("MR. PONG WINS %d-%d" % (score_a, score_b) if score_a > score_b
|
| 219 |
+
else ("%s WINS %d-%d" % (opponent_name.upper(), score_b, score_a)
|
| 220 |
+
if score_b > score_a else "TIME LIMIT"))
|
| 221 |
+
for _ in range(FPS * 2):
|
| 222 |
+
if len(frames) < MAX_FRAMES + FPS * 2:
|
| 223 |
+
frames.append(_render_frame(env, score_a, score_b, opponent_name,
|
| 224 |
+
[], banner=banner))
|
| 225 |
+
|
| 226 |
+
sim_s = time.perf_counter() - t0
|
| 227 |
+
out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
|
| 228 |
+
with imageio.get_writer(out, fps=FPS, codec="libx264", quality=7,
|
| 229 |
+
macro_block_size=1, pixelformat="yuv420p") as w:
|
| 230 |
+
for f in frames:
|
| 231 |
+
w.append_data(f)
|
| 232 |
+
|
| 233 |
+
stats = (
|
| 234 |
+
f"### Mr. Pong **{score_a} – {score_b}** {opponent_name}\n"
|
| 235 |
+
f"| | |\n|---|---|\n"
|
| 236 |
+
f"| Points played | {len(rallies)} |\n"
|
| 237 |
+
f"| Longest rally | {max(rallies)} hits |\n"
|
| 238 |
+
f"| Average rally | {np.mean(rallies):.1f} hits |\n"
|
| 239 |
+
f"| Video length | {len(frames) / FPS:.1f} s ({len(frames)} frames) |\n"
|
| 240 |
+
f"| Simulation time | {sim_s:.2f} s |\n"
|
| 241 |
+
f"| Seed | `{seed}` |\n"
|
| 242 |
+
)
|
| 243 |
+
return out, stats, seed
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def benchmark(opponent_name: str, num_matches: int = 25, seed: int = 0):
|
| 247 |
+
"""Replay the model card's evaluation: N single-point matches, no rendering.
|
| 248 |
+
|
| 249 |
+
Args:
|
| 250 |
+
opponent_name: which scripted baseline to face.
|
| 251 |
+
num_matches: how many matches to simulate.
|
| 252 |
+
seed: RNG seed for reproducibility.
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
A markdown summary of the win / draw / loss record.
|
| 256 |
+
"""
|
| 257 |
+
seed = int(seed)
|
| 258 |
+
rng = random.Random(seed)
|
| 259 |
+
random.seed(seed)
|
| 260 |
+
num_matches = int(num_matches)
|
| 261 |
+
|
| 262 |
+
opponent = OPPONENTS[opponent_name](rng=rng)
|
| 263 |
+
env = StandalonePongEnv(PhysicsConfig(), seed=seed)
|
| 264 |
+
|
| 265 |
+
wins = draws = losses = 0
|
| 266 |
+
rallies = []
|
| 267 |
+
t0 = time.perf_counter()
|
| 268 |
+
|
| 269 |
+
for i in range(1, num_matches + 1):
|
| 270 |
+
obs = env.reset(serve_direction=1 if i % 2 == 0 else -1)
|
| 271 |
+
done = False
|
| 272 |
+
info = {}
|
| 273 |
+
while not done:
|
| 274 |
+
ego_act = policy_action(obs)
|
| 275 |
+
opp_act = opponent.act(env)
|
| 276 |
+
obs, done, info = env.step(ego_action=ego_act, opp_action=opp_act)
|
| 277 |
+
rallies.append(env.rally_count)
|
| 278 |
+
w = info.get("winner")
|
| 279 |
+
if w == "ego":
|
| 280 |
+
wins += 1
|
| 281 |
+
elif w == "opponent":
|
| 282 |
+
losses += 1
|
| 283 |
+
else:
|
| 284 |
+
draws += 1
|
| 285 |
+
|
| 286 |
+
el = time.perf_counter() - t0
|
| 287 |
+
n = max(1, num_matches)
|
| 288 |
+
return (
|
| 289 |
+
f"### Mr. Pong vs **{opponent_name}** — {num_matches} matches\n\n"
|
| 290 |
+
f"| Metric | Value |\n|---|---|\n"
|
| 291 |
+
f"| Win rate | **{wins / n * 100:.1f}%** |\n"
|
| 292 |
+
f"| Draw rate | {draws / n * 100:.1f}% |\n"
|
| 293 |
+
f"| Loss rate | {losses / n * 100:.1f}% |\n"
|
| 294 |
+
f"| Record (W/D/L) | {wins}W / {draws}D / {losses}L |\n"
|
| 295 |
+
f"| Average rally | {np.mean(rallies):.1f} hits (max {max(rallies)}) |\n"
|
| 296 |
+
f"| Wall time | {el:.2f} s ({num_matches / max(el, 1e-6):.1f} matches/s) |\n\n"
|
| 297 |
+
f"*A draw means the rally hit the {env.phys.max_rally_steps}-step limit "
|
| 298 |
+
f"without either side scoring.*"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ----------------------------------------------------------------------------
|
| 303 |
+
# UI
|
| 304 |
+
# ----------------------------------------------------------------------------
|
| 305 |
+
CSS = """
|
| 306 |
+
#col-container { max-width: 1000px; margin: 0 auto; }
|
| 307 |
+
.dark .gradio-container { color: var(--body-text-color); }
|
| 308 |
+
"""
|
| 309 |
+
|
| 310 |
+
INTRO = f"""# 🏓 Mr. Pong
|
| 311 |
+
|
| 312 |
+
[**fromziro/MrPong**](https://huggingface.co/{MODEL_ID}) is a {N_PARAMS:,}-parameter
|
| 313 |
+
actor–critic MLP trained with PPO and self-play for 10M steps to play 2D table tennis.
|
| 314 |
+
It wins ~87% of its matches against the strongest scripted baseline it was trained on.
|
| 315 |
+
|
| 316 |
+
**Play it yourself below** — the policy runs in your browser, so the paddle answers
|
| 317 |
+
in real time. Or watch the real PyTorch checkpoint take on the model-card opponents.
|
| 318 |
+
"""
|
| 319 |
+
|
| 320 |
+
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Mr. Pong") as demo:
|
| 321 |
+
with gr.Column(elem_id="col-container"):
|
| 322 |
+
gr.Markdown(INTRO)
|
| 323 |
+
|
| 324 |
+
with gr.Tabs():
|
| 325 |
+
with gr.Tab("🎮 Play Mr. Pong"):
|
| 326 |
+
gr.HTML(GAME_IFRAME)
|
| 327 |
+
gr.Markdown(
|
| 328 |
+
"Click the court to start. Hold **W** / **↑** and **S** / **↓** "
|
| 329 |
+
"(or switch to mouse / touch control). First to 5 points. \n"
|
| 330 |
+
"*V(s) is the critic's value estimate from Mr. Pong's point of "
|
| 331 |
+
"view — it climbs when the agent thinks it is about to win the point.*"
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
with gr.Tab("🎬 Watch a match"):
|
| 335 |
+
with gr.Row():
|
| 336 |
+
opponent = gr.Dropdown(
|
| 337 |
+
list(OPPONENTS.keys()), value="Realistic Hard",
|
| 338 |
+
label="Opponent", scale=3)
|
| 339 |
+
watch_btn = gr.Button("Play match", variant="primary", scale=1)
|
| 340 |
+
video_out = gr.Video(label="Match replay", autoplay=True,
|
| 341 |
+
height=430, format="mp4")
|
| 342 |
+
stats_out = gr.Markdown()
|
| 343 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 344 |
+
points = gr.Slider(1, 5, value=3, step=1, label="Points to win")
|
| 345 |
+
rand_seed = gr.Checkbox(value=True, label="Randomize seed")
|
| 346 |
+
seed_num = gr.Number(value=0, precision=0, label="Seed")
|
| 347 |
+
gr.Examples(
|
| 348 |
+
examples=[["Realistic Hard"], ["Impossible Hard"],
|
| 349 |
+
["Medium Logic"], ["Random Agent"]],
|
| 350 |
+
inputs=[opponent],
|
| 351 |
+
outputs=[video_out, stats_out, seed_num],
|
| 352 |
+
fn=watch_match,
|
| 353 |
+
cache_examples=True,
|
| 354 |
+
cache_mode="lazy",
|
| 355 |
+
label="Opponents from the model card",
|
| 356 |
+
)
|
| 357 |
+
watch_btn.click(
|
| 358 |
+
watch_match,
|
| 359 |
+
inputs=[opponent, points, rand_seed, seed_num],
|
| 360 |
+
outputs=[video_out, stats_out, seed_num],
|
| 361 |
+
api_name="watch_match",
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
with gr.Tab("📊 Benchmark"):
|
| 365 |
+
with gr.Row():
|
| 366 |
+
bench_opp = gr.Dropdown(
|
| 367 |
+
list(OPPONENTS.keys()), value="Realistic Hard",
|
| 368 |
+
label="Opponent", scale=3)
|
| 369 |
+
bench_btn = gr.Button("Run benchmark", variant="primary", scale=1)
|
| 370 |
+
bench_out = gr.Markdown()
|
| 371 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 372 |
+
n_matches = gr.Slider(5, 200, value=25, step=5, label="Matches")
|
| 373 |
+
bench_seed = gr.Number(value=0, precision=0, label="Seed")
|
| 374 |
+
gr.Markdown(
|
| 375 |
+
"Reference numbers reported by the author over 1000 matches: \n"
|
| 376 |
+
"Easy 99.7% · Medium 99.0% · Realistic Hard 86.6% · "
|
| 377 |
+
"Random 100% · Impossible Hard 0% W / 98.4% D."
|
| 378 |
+
)
|
| 379 |
+
bench_btn.click(
|
| 380 |
+
benchmark,
|
| 381 |
+
inputs=[bench_opp, n_matches, bench_seed],
|
| 382 |
+
outputs=[bench_out],
|
| 383 |
+
api_name="benchmark",
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
gr.Markdown(
|
| 387 |
+
"Model, physics environment and baseline opponents by "
|
| 388 |
+
"[FromZero](https://huggingface.co/fromziro) (Apache-2.0). "
|
| 389 |
+
"The in-browser policy is the exact checkpoint re-evaluated in JavaScript; "
|
| 390 |
+
"the *Watch* and *Benchmark* tabs run the PyTorch model itself."
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
if __name__ == "__main__":
|
| 394 |
+
demo.launch(mcp_server=True)
|
game_template.html
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<style>
|
| 7 |
+
* { box-sizing: border-box; }
|
| 8 |
+
html, body {
|
| 9 |
+
margin: 0; padding: 0; background: #0b0f19; color: #e6edf3;
|
| 10 |
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
| 11 |
+
overflow: hidden;
|
| 12 |
+
}
|
| 13 |
+
#wrap { padding: 8px 10px 10px 10px; max-width: 900px; margin: 0 auto; }
|
| 14 |
+
#bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
|
| 15 |
+
.btn {
|
| 16 |
+
background: #1b2438; color: #e6edf3;
|
| 17 |
+
border: 1px solid #2b3550; border-radius: 8px; padding: 6px 12px; cursor: pointer;
|
| 18 |
+
font-size: 13px; font-weight: 600; transition: all .12s;
|
| 19 |
+
}
|
| 20 |
+
.btn:hover { background: #26314a; }
|
| 21 |
+
.btn.active { background: #f97316; border-color: #f97316; color: #0b0f19; }
|
| 22 |
+
.btn.primary { background: #4f8cff; border-color: #4f8cff; color: #08111f; }
|
| 23 |
+
.btn.primary:hover { background: #6ba0ff; }
|
| 24 |
+
.lab { font-size: 12px; color: #8b98ac; text-transform: uppercase; letter-spacing: .06em; margin-right: 2px; }
|
| 25 |
+
#stage { position: relative; width: 100%; }
|
| 26 |
+
canvas { width: 100%; height: auto; display: block; border-radius: 10px; background: #0f1626; }
|
| 27 |
+
#overlay {
|
| 28 |
+
position: absolute; inset: 0; display: flex; flex-direction: column;
|
| 29 |
+
align-items: center; justify-content: center; text-align: center;
|
| 30 |
+
background: rgba(8,12,22,.82); border-radius: 10px; cursor: pointer;
|
| 31 |
+
}
|
| 32 |
+
#overlay h2 { margin: 0 0 6px 0; font-size: 22px; }
|
| 33 |
+
#overlay p { margin: 2px 0; font-size: 13px; color: #b6c2d4; }
|
| 34 |
+
#hud { display: flex; gap: 14px; align-items: center; margin-top: 8px; font-size: 12px; color: #8b98ac; flex-wrap: wrap; }
|
| 35 |
+
#vbar { flex: 1; min-width: 120px; height: 8px; background: #1b2438; border-radius: 4px; overflow: hidden; position: relative; }
|
| 36 |
+
#vfill { position: absolute; top: 0; bottom: 0; left: 50%; width: 0%; background: #4f8cff; transition: width .1s linear, left .1s linear; }
|
| 37 |
+
kbd { background:#1b2438; border:1px solid #2b3550; border-bottom-width:2px; border-radius:5px; padding:1px 6px; font-size:12px; }
|
| 38 |
+
</style>
|
| 39 |
+
</head>
|
| 40 |
+
<body>
|
| 41 |
+
<div id="wrap">
|
| 42 |
+
<div id="bar">
|
| 43 |
+
<span class="lab">Difficulty</span>
|
| 44 |
+
<button class="btn diff" data-d="easy">Easy</button>
|
| 45 |
+
<button class="btn diff active" data-d="normal">Normal</button>
|
| 46 |
+
<button class="btn diff" data-d="hard">Hard</button>
|
| 47 |
+
<span style="flex:1"></span>
|
| 48 |
+
<span class="lab">Control</span>
|
| 49 |
+
<button class="btn ctrl active" data-c="key">Keyboard</button>
|
| 50 |
+
<button class="btn ctrl" data-c="mouse">Mouse / Touch</button>
|
| 51 |
+
<button class="btn primary" id="restart">Restart</button>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div id="stage">
|
| 55 |
+
<canvas id="cv" width="800" height="500"></canvas>
|
| 56 |
+
<div id="overlay">
|
| 57 |
+
<h2>🏓 Click here to play Mr. Pong</h2>
|
| 58 |
+
<p>Hold <kbd>W</kbd>/<kbd>↑</kbd> to move up, <kbd>S</kbd>/<kbd>↓</kbd> to move down — release to stop.</p>
|
| 59 |
+
<p>First to 5 points. You are the <b style="color:#22d3ee">cyan</b> paddle on the left.</p>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<div id="hud">
|
| 64 |
+
<span>Mr. Pong action: <b id="act" style="color:#e6edf3">STAY</b></span>
|
| 65 |
+
<span>V(s): <b id="vtxt" style="color:#e6edf3">0.00</b></span>
|
| 66 |
+
<div id="vbar"><div id="vfill"></div></div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<script>
|
| 71 |
+
// ---------------------------------------------------------------------------
|
| 72 |
+
// Mr. Pong policy network (fromziro/MrPong) — 28,484 params, exported from the
|
| 73 |
+
// checkpoint shipped on the Hub and evaluated here in the browser.
|
| 74 |
+
// trunk: Linear(12,160) -> Tanh -> Linear(160,160) -> Tanh
|
| 75 |
+
// actor: Linear(160,3) (argmax => stay / up / down) critic: Linear(160,1)
|
| 76 |
+
// ---------------------------------------------------------------------------
|
| 77 |
+
const B64 = "__WEIGHTS_B64__";
|
| 78 |
+
const WB = (function () {
|
| 79 |
+
const bin = atob(B64);
|
| 80 |
+
const buf = new ArrayBuffer(bin.length);
|
| 81 |
+
const u8 = new Uint8Array(buf);
|
| 82 |
+
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
| 83 |
+
return new Float32Array(buf);
|
| 84 |
+
})();
|
| 85 |
+
const OBS = 12, HID = 160, ACT = 3;
|
| 86 |
+
const O_L0W = 0, O_L0B = O_L0W + HID * OBS;
|
| 87 |
+
const O_L2W = O_L0B + HID, O_L2B = O_L2W + HID * HID;
|
| 88 |
+
const O_AW = O_L2B + HID, O_AB = O_AW + ACT * HID;
|
| 89 |
+
const O_CW = O_AB + ACT, O_CB = O_CW + HID;
|
| 90 |
+
|
| 91 |
+
const h1 = new Float32Array(HID), h2 = new Float32Array(HID);
|
| 92 |
+
function policy(x) {
|
| 93 |
+
for (let i = 0; i < HID; i++) {
|
| 94 |
+
let s = WB[O_L0B + i], b = O_L0W + i * OBS;
|
| 95 |
+
for (let j = 0; j < OBS; j++) s += WB[b + j] * x[j];
|
| 96 |
+
h1[i] = Math.tanh(s);
|
| 97 |
+
}
|
| 98 |
+
for (let i = 0; i < HID; i++) {
|
| 99 |
+
let s = WB[O_L2B + i], b = O_L2W + i * HID;
|
| 100 |
+
for (let j = 0; j < HID; j++) s += WB[b + j] * h1[j];
|
| 101 |
+
h2[i] = Math.tanh(s);
|
| 102 |
+
}
|
| 103 |
+
let best = 0, bv = -1e30;
|
| 104 |
+
for (let i = 0; i < ACT; i++) {
|
| 105 |
+
let s = WB[O_AB + i], b = O_AW + i * HID;
|
| 106 |
+
for (let j = 0; j < HID; j++) s += WB[b + j] * h2[j];
|
| 107 |
+
if (s > bv) { bv = s; best = i; }
|
| 108 |
+
}
|
| 109 |
+
let v = WB[O_CB];
|
| 110 |
+
for (let j = 0; j < HID; j++) v += WB[O_CW + j] * h2[j];
|
| 111 |
+
return [best, v];
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// ---------------------------------------------------------------------------
|
| 115 |
+
// Physics — 1:1 port of StandalonePongEnv from the author's inference.py
|
| 116 |
+
// ---------------------------------------------------------------------------
|
| 117 |
+
const W = 800, H = 500, PW = 14, R = 8, SMOOTH = 0.70, VMAX = 16, ACCEL = 1.035, SKIP = 3;
|
| 118 |
+
const PRESETS = {
|
| 119 |
+
easy: { pspeed: 9.0, serve: 3.5, egoH: 110, dt: 28 },
|
| 120 |
+
normal: { pspeed: 8.5, serve: 4.2, egoH: 95, dt: 25 },
|
| 121 |
+
hard: { pspeed: 8.0, serve: 6.0, egoH: 80, dt: 22 }
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
let P = PRESETS.normal;
|
| 125 |
+
let egoY, oppY, egoVy, oppVy, ballX, ballY, ballVx, ballVy, rally, egoH = 95, oppH = 80;
|
| 126 |
+
let scoreH = 0, scoreA = 0, maxRally = 0, aiAct = 0, aiVal = 0;
|
| 127 |
+
let running = false, phase = "idle", phaseT = 0, banner = "", serveDir = 1, sub = 0;
|
| 128 |
+
let trail = [];
|
| 129 |
+
|
| 130 |
+
function resetPoint(dir) {
|
| 131 |
+
egoY = H / 2; oppY = H / 2; egoVy = 0; oppVy = 0;
|
| 132 |
+
ballX = W / 2; ballY = H / 2;
|
| 133 |
+
const ang = (Math.random() * 2 - 1) * (Math.PI / 7);
|
| 134 |
+
ballVx = dir * P.serve * Math.cos(ang);
|
| 135 |
+
ballVy = P.serve * Math.sin(ang);
|
| 136 |
+
rally = 0; sub = 0; trail = [];
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function interceptY(targetX, bx, by, bvx, bvy) {
|
| 140 |
+
if ((targetX > bx && bvx <= 0) || (targetX < bx && bvx >= 0)) return H / 2;
|
| 141 |
+
for (let k = 0; k < 10; k++) {
|
| 142 |
+
const dtx = bvx !== 0 ? (targetX - bx) / bvx : Infinity;
|
| 143 |
+
if (dtx <= 0) break;
|
| 144 |
+
let dty;
|
| 145 |
+
if (bvy > 0) dty = (H - R - by) / bvy;
|
| 146 |
+
else if (bvy < 0) dty = (R - by) / bvy;
|
| 147 |
+
else dty = Infinity;
|
| 148 |
+
if (dtx <= dty) { by += bvy * dtx; break; }
|
| 149 |
+
bx += bvx * dty; by += bvy * dty; bvy = -bvy;
|
| 150 |
+
}
|
| 151 |
+
return Math.min(H - R, Math.max(R, by));
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
const obsBuf = new Float32Array(OBS);
|
| 155 |
+
function oppObservation() {
|
| 156 |
+
const oppX = W - PW;
|
| 157 |
+
const pred = interceptY(oppX, ballX, ballY, ballVx, ballVy);
|
| 158 |
+
obsBuf[0] = (ballY - oppY) / H;
|
| 159 |
+
obsBuf[1] = (oppX - ballX) / W;
|
| 160 |
+
obsBuf[2] = -ballVx / VMAX;
|
| 161 |
+
obsBuf[3] = ballVy / VMAX;
|
| 162 |
+
obsBuf[4] = oppY / H;
|
| 163 |
+
obsBuf[5] = oppVy / P.pspeed;
|
| 164 |
+
obsBuf[6] = (egoY - oppY) / H;
|
| 165 |
+
obsBuf[7] = egoVy / P.pspeed;
|
| 166 |
+
obsBuf[8] = ballY / H;
|
| 167 |
+
obsBuf[9] = (W - ballX) / W;
|
| 168 |
+
obsBuf[10] = (pred - oppY) / H;
|
| 169 |
+
obsBuf[11] = pred / H;
|
| 170 |
+
return obsBuf;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function actionVel(a) { return a === 1 ? -P.pspeed : (a === 2 ? P.pspeed : 0); }
|
| 174 |
+
|
| 175 |
+
function substep(egoAct, oppAct) {
|
| 176 |
+
const pEgoY = egoY, pOppY = oppY;
|
| 177 |
+
egoVy = SMOOTH * egoVy + (1 - SMOOTH) * actionVel(egoAct);
|
| 178 |
+
oppVy = SMOOTH * oppVy + (1 - SMOOTH) * actionVel(oppAct);
|
| 179 |
+
const eh = egoH / 2, oh = oppH / 2;
|
| 180 |
+
egoY = Math.min(H - eh, Math.max(eh, egoY + egoVy));
|
| 181 |
+
oppY = Math.min(H - oh, Math.max(oh, oppY + oppVy));
|
| 182 |
+
|
| 183 |
+
const pbx = ballX, pby = ballY;
|
| 184 |
+
const egoPlane = PW + R, oppPlane = (W - PW) - R;
|
| 185 |
+
const nbx = pbx + ballVx, nby = pby + ballVy;
|
| 186 |
+
let hit = false;
|
| 187 |
+
|
| 188 |
+
if (ballVx < 0 && pbx >= egoPlane && nbx <= egoPlane) {
|
| 189 |
+
const t = Math.min(1, Math.max(0, (pbx - egoPlane) / Math.max(1e-6, -ballVx)));
|
| 190 |
+
const yb = pby + t * ballVy, yp = pEgoY + t * (egoY - pEgoY);
|
| 191 |
+
if (Math.abs(yb - yp) <= eh + R * 0.6) {
|
| 192 |
+
hit = true; rally++;
|
| 193 |
+
const off = Math.min(1, Math.max(-1, (yb - yp) / eh));
|
| 194 |
+
const ang = off * (Math.PI / 3);
|
| 195 |
+
const sp = Math.min(Math.hypot(ballVx, ballVy) * ACCEL, VMAX);
|
| 196 |
+
const nvx = sp * Math.cos(ang), nvy = sp * Math.sin(ang) + 0.25 * egoVy;
|
| 197 |
+
const rem = 1 - t;
|
| 198 |
+
ballX = egoPlane + rem * nvx; ballY = yb + rem * nvy; ballVx = nvx; ballVy = nvy;
|
| 199 |
+
}
|
| 200 |
+
} else if (ballVx > 0 && pbx <= oppPlane && nbx >= oppPlane) {
|
| 201 |
+
const t = Math.min(1, Math.max(0, (oppPlane - pbx) / Math.max(1e-6, ballVx)));
|
| 202 |
+
const yb = pby + t * ballVy, yp = pOppY + t * (oppY - pOppY);
|
| 203 |
+
if (Math.abs(yb - yp) <= oh + R * 0.6) {
|
| 204 |
+
hit = true; rally++;
|
| 205 |
+
const off = Math.min(1, Math.max(-1, (yb - yp) / oh));
|
| 206 |
+
const ang = off * (Math.PI / 3);
|
| 207 |
+
const sp = Math.min(Math.hypot(ballVx, ballVy) * ACCEL, VMAX);
|
| 208 |
+
const nvx = -sp * Math.cos(ang), nvy = sp * Math.sin(ang) + 0.25 * oppVy;
|
| 209 |
+
const rem = 1 - t;
|
| 210 |
+
ballX = oppPlane + rem * nvx; ballY = yb + rem * nvy; ballVx = nvx; ballVy = nvy;
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
if (!hit) { ballX = nbx; ballY = nby; }
|
| 214 |
+
|
| 215 |
+
if (ballY - R <= 0) { ballY = R + Math.abs(R - ballY); ballVy = Math.abs(ballVy); }
|
| 216 |
+
else if (ballY + R >= H) { ballY = (H - R) - Math.abs(ballY + R - H); ballVy = -Math.abs(ballVy); }
|
| 217 |
+
|
| 218 |
+
if (ballX - R < 0) return "opponent";
|
| 219 |
+
if (ballX + R > W) return "ego";
|
| 220 |
+
return null;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// ---------------------------------------------------------------------------
|
| 224 |
+
// Input
|
| 225 |
+
// ---------------------------------------------------------------------------
|
| 226 |
+
let keyUp = false, keyDown = false, ctrlMode = "key", pointerY = null;
|
| 227 |
+
const KUP = ["w", "W", "ArrowUp"], KDN = ["s", "S", "ArrowDown"];
|
| 228 |
+
document.addEventListener("keydown", e => {
|
| 229 |
+
if (KUP.includes(e.key)) { keyUp = true; e.preventDefault(); }
|
| 230 |
+
else if (KDN.includes(e.key)) { keyDown = true; e.preventDefault(); }
|
| 231 |
+
});
|
| 232 |
+
document.addEventListener("keyup", e => {
|
| 233 |
+
if (KUP.includes(e.key)) { keyUp = false; e.preventDefault(); }
|
| 234 |
+
else if (KDN.includes(e.key)) { keyDown = false; e.preventDefault(); }
|
| 235 |
+
});
|
| 236 |
+
window.addEventListener("blur", () => { keyUp = false; keyDown = false; });
|
| 237 |
+
|
| 238 |
+
const cv = document.getElementById("cv");
|
| 239 |
+
function pointerFromEvent(e) {
|
| 240 |
+
const r = cv.getBoundingClientRect();
|
| 241 |
+
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
|
| 242 |
+
pointerY = Math.min(H, Math.max(0, cy * (H / r.height)));
|
| 243 |
+
}
|
| 244 |
+
cv.addEventListener("mousemove", pointerFromEvent);
|
| 245 |
+
cv.addEventListener("touchmove", e => { pointerFromEvent(e); e.preventDefault(); }, { passive: false });
|
| 246 |
+
cv.addEventListener("touchstart", pointerFromEvent, { passive: true });
|
| 247 |
+
|
| 248 |
+
function humanAction() {
|
| 249 |
+
if (ctrlMode === "mouse" && pointerY !== null) {
|
| 250 |
+
const d = pointerY - egoY;
|
| 251 |
+
if (Math.abs(d) < 6) return 0;
|
| 252 |
+
return d > 0 ? 2 : 1;
|
| 253 |
+
}
|
| 254 |
+
if (keyUp && !keyDown) return 1;
|
| 255 |
+
if (keyDown && !keyUp) return 2;
|
| 256 |
+
return 0;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// ---------------------------------------------------------------------------
|
| 260 |
+
// Render
|
| 261 |
+
// ---------------------------------------------------------------------------
|
| 262 |
+
const ctx = cv.getContext("2d");
|
| 263 |
+
const ACTNAMES = ["STAY", "UP", "DOWN"];
|
| 264 |
+
function draw() {
|
| 265 |
+
ctx.fillStyle = "#0f1626";
|
| 266 |
+
ctx.fillRect(0, 0, W, H);
|
| 267 |
+
|
| 268 |
+
ctx.strokeStyle = "rgba(255,255,255,.10)";
|
| 269 |
+
ctx.lineWidth = 3; ctx.setLineDash([12, 14]);
|
| 270 |
+
ctx.beginPath(); ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke();
|
| 271 |
+
ctx.setLineDash([]);
|
| 272 |
+
ctx.lineWidth = 2; ctx.strokeStyle = "rgba(255,255,255,.07)";
|
| 273 |
+
ctx.strokeRect(1, 1, W - 2, H - 2);
|
| 274 |
+
|
| 275 |
+
for (let i = 0; i < trail.length; i++) {
|
| 276 |
+
const p = trail[i], a = (i + 1) / (trail.length + 1);
|
| 277 |
+
ctx.globalAlpha = a * 0.35;
|
| 278 |
+
ctx.fillStyle = "#e6edf3";
|
| 279 |
+
ctx.beginPath(); ctx.arc(p[0], p[1], R * (0.35 + 0.6 * a), 0, 6.2832); ctx.fill();
|
| 280 |
+
}
|
| 281 |
+
ctx.globalAlpha = 1;
|
| 282 |
+
|
| 283 |
+
ctx.fillStyle = "#22d3ee";
|
| 284 |
+
roundRect(2, egoY - egoH / 2, PW, egoH, 6);
|
| 285 |
+
ctx.fillStyle = "#4f8cff";
|
| 286 |
+
roundRect(W - PW - 2, oppY - oppH / 2, PW, oppH, 6);
|
| 287 |
+
|
| 288 |
+
ctx.fillStyle = "#ffffff";
|
| 289 |
+
ctx.beginPath(); ctx.arc(ballX, ballY, R, 0, 6.2832); ctx.fill();
|
| 290 |
+
|
| 291 |
+
ctx.font = "700 46px ui-sans-serif, system-ui, sans-serif";
|
| 292 |
+
ctx.textAlign = "center";
|
| 293 |
+
ctx.fillStyle = "rgba(34,211,238,.55)";
|
| 294 |
+
ctx.fillText(String(scoreH), W / 2 - 60, 58);
|
| 295 |
+
ctx.fillStyle = "rgba(79,140,255,.55)";
|
| 296 |
+
ctx.fillText(String(scoreA), W / 2 + 60, 58);
|
| 297 |
+
|
| 298 |
+
ctx.font = "600 13px ui-sans-serif, system-ui, sans-serif";
|
| 299 |
+
ctx.fillStyle = "rgba(230,237,243,.45)";
|
| 300 |
+
ctx.textAlign = "left";
|
| 301 |
+
ctx.fillText("YOU", 26, 34);
|
| 302 |
+
ctx.textAlign = "right";
|
| 303 |
+
ctx.fillText("MR. PONG", W - 26, 34);
|
| 304 |
+
ctx.textAlign = "left";
|
| 305 |
+
ctx.fillText("Rally " + rally + " hits · Ball " + Math.hypot(ballVx, ballVy).toFixed(1) + " px/f · Longest " + maxRally, 26, H - 20);
|
| 306 |
+
|
| 307 |
+
if (banner) {
|
| 308 |
+
ctx.textAlign = "center";
|
| 309 |
+
ctx.font = "800 40px ui-sans-serif, system-ui, sans-serif";
|
| 310 |
+
ctx.fillStyle = "rgba(11,15,25,.72)";
|
| 311 |
+
ctx.fillRect(0, H / 2 - 46, W, 92);
|
| 312 |
+
ctx.fillStyle = "#f97316";
|
| 313 |
+
ctx.fillText(banner, W / 2, H / 2 + 14);
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
function roundRect(x, y, w, h, r) {
|
| 317 |
+
ctx.beginPath();
|
| 318 |
+
ctx.moveTo(x + r, y);
|
| 319 |
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
| 320 |
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
| 321 |
+
ctx.arcTo(x, y + h, x, y, r);
|
| 322 |
+
ctx.arcTo(x, y, x + w, y, r);
|
| 323 |
+
ctx.closePath(); ctx.fill();
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
// ---------------------------------------------------------------------------
|
| 327 |
+
// Loop
|
| 328 |
+
// ---------------------------------------------------------------------------
|
| 329 |
+
const actEl = document.getElementById("act");
|
| 330 |
+
const vtxtEl = document.getElementById("vtxt");
|
| 331 |
+
const vfillEl = document.getElementById("vfill");
|
| 332 |
+
let acc = 0, last = 0;
|
| 333 |
+
|
| 334 |
+
function tick(ts) {
|
| 335 |
+
if (!last) last = ts;
|
| 336 |
+
let dt = ts - last; last = ts;
|
| 337 |
+
if (dt > 200) dt = 200;
|
| 338 |
+
|
| 339 |
+
if (running) {
|
| 340 |
+
if (phase === "count") {
|
| 341 |
+
phaseT -= dt;
|
| 342 |
+
const n = Math.ceil(phaseT / 600);
|
| 343 |
+
banner = n > 0 ? "SERVING IN " + n : "GO!";
|
| 344 |
+
if (phaseT <= 0) { banner = ""; phase = "rally"; acc = 0; }
|
| 345 |
+
} else if (phase === "rally") {
|
| 346 |
+
acc += dt;
|
| 347 |
+
let guard = 0;
|
| 348 |
+
while (acc >= P.dt && guard < 12) {
|
| 349 |
+
acc -= P.dt; guard++;
|
| 350 |
+
if (sub % SKIP === 0) {
|
| 351 |
+
const out = policy(oppObservation());
|
| 352 |
+
aiAct = out[0]; aiVal = out[1];
|
| 353 |
+
}
|
| 354 |
+
sub++;
|
| 355 |
+
trail.push([ballX, ballY]);
|
| 356 |
+
if (trail.length > 9) trail.shift();
|
| 357 |
+
const winner = substep(humanAction(), aiAct);
|
| 358 |
+
if (rally > maxRally) maxRally = rally;
|
| 359 |
+
if (winner) {
|
| 360 |
+
if (winner === "ego") { scoreH++; serveDir = 1; banner = "POINT — YOU!"; }
|
| 361 |
+
else { scoreA++; serveDir = -1; banner = "POINT — MR. PONG"; }
|
| 362 |
+
if (scoreH >= 5 || scoreA >= 5) {
|
| 363 |
+
banner = scoreH >= 5 ? "🏆 YOU WIN " + scoreH + "–" + scoreA : "🤖 MR. PONG WINS " + scoreA + "–" + scoreH;
|
| 364 |
+
phase = "over"; running = false;
|
| 365 |
+
document.getElementById("overlay").style.display = "flex";
|
| 366 |
+
document.getElementById("overlay").innerHTML =
|
| 367 |
+
"<h2>" + banner + "</h2><p>Longest rally: " + maxRally + " hits</p><p>Click to play again</p>";
|
| 368 |
+
} else {
|
| 369 |
+
phase = "pause"; phaseT = 1100;
|
| 370 |
+
}
|
| 371 |
+
break;
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
} else if (phase === "pause") {
|
| 375 |
+
phaseT -= dt;
|
| 376 |
+
if (phaseT <= 0) { resetPoint(serveDir); phase = "count"; phaseT = 1900; }
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
actEl.textContent = ACTNAMES[aiAct];
|
| 381 |
+
vtxtEl.textContent = aiVal.toFixed(2);
|
| 382 |
+
const v = Math.max(-1, Math.min(1, aiVal / 3));
|
| 383 |
+
vfillEl.style.left = (v >= 0 ? 50 : 50 + v * 50) + "%";
|
| 384 |
+
vfillEl.style.width = Math.abs(v) * 50 + "%";
|
| 385 |
+
vfillEl.style.background = v >= 0 ? "#4f8cff" : "#f97316";
|
| 386 |
+
|
| 387 |
+
draw();
|
| 388 |
+
requestAnimationFrame(tick);
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
function startMatch() {
|
| 392 |
+
scoreH = 0; scoreA = 0; maxRally = 0; serveDir = 1;
|
| 393 |
+
resetPoint(serveDir);
|
| 394 |
+
banner = ""; phase = "count"; phaseT = 1900; running = true; last = 0; acc = 0;
|
| 395 |
+
document.getElementById("overlay").style.display = "none";
|
| 396 |
+
window.focus();
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
document.getElementById("overlay").addEventListener("click", startMatch);
|
| 400 |
+
document.getElementById("restart").addEventListener("click", startMatch);
|
| 401 |
+
document.querySelectorAll(".diff").forEach(b => b.addEventListener("click", () => {
|
| 402 |
+
document.querySelectorAll(".diff").forEach(x => x.classList.remove("active"));
|
| 403 |
+
b.classList.add("active");
|
| 404 |
+
P = PRESETS[b.dataset.d]; egoH = P.egoH;
|
| 405 |
+
startMatch();
|
| 406 |
+
}));
|
| 407 |
+
document.querySelectorAll(".ctrl").forEach(b => b.addEventListener("click", () => {
|
| 408 |
+
document.querySelectorAll(".ctrl").forEach(x => x.classList.remove("active"));
|
| 409 |
+
b.classList.add("active");
|
| 410 |
+
ctrlMode = b.dataset.c;
|
| 411 |
+
}));
|
| 412 |
+
|
| 413 |
+
egoH = P.egoH;
|
| 414 |
+
resetPoint(1);
|
| 415 |
+
requestAnimationFrame(tick);
|
| 416 |
+
</script>
|
| 417 |
+
</body>
|
| 418 |
+
</html>
|
pong_engine.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Standalone table-tennis physics environment + baseline opponents for Mr. Pong.
|
| 3 |
+
|
| 4 |
+
This is a faithful port of the reference implementation shipped by the model
|
| 5 |
+
author in `inference.py` at https://huggingface.co/fromziro/MrPong (Apache-2.0).
|
| 6 |
+
Physics constants, collision handling, observation layout and the baseline
|
| 7 |
+
opponent policies are kept 1:1 with the original so that the behaviour shown in
|
| 8 |
+
this Space matches the numbers reported on the model card.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
import random
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Any, Dict, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class PhysicsConfig:
|
| 21 |
+
table_width: float = 800.0
|
| 22 |
+
table_height: float = 500.0
|
| 23 |
+
paddle_width: float = 14.0
|
| 24 |
+
paddle_height: float = 80.0
|
| 25 |
+
paddle_speed: float = 8.0
|
| 26 |
+
paddle_smoothing: float = 0.70
|
| 27 |
+
ball_radius: float = 8.0
|
| 28 |
+
ball_speed_initial: float = 8.0
|
| 29 |
+
ball_speed_max: float = 16.0
|
| 30 |
+
ball_acceleration: float = 1.035
|
| 31 |
+
frame_skip: int = 3
|
| 32 |
+
max_rally_steps: int = 1500
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class StandalonePongEnv:
|
| 36 |
+
"""Self-contained table tennis physics environment (port of the author's env)."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, phys: Optional[PhysicsConfig] = None, seed: Optional[int] = None):
|
| 39 |
+
self.phys = phys or PhysicsConfig()
|
| 40 |
+
self.rng = random.Random(seed)
|
| 41 |
+
self.ego_paddle_h = self.phys.paddle_height
|
| 42 |
+
self.opp_paddle_h = self.phys.paddle_height
|
| 43 |
+
self.reset()
|
| 44 |
+
|
| 45 |
+
def reset(self, serve_direction: Optional[int] = None,
|
| 46 |
+
initial_speed: Optional[float] = None) -> np.ndarray:
|
| 47 |
+
self.ego_y = self.phys.table_height / 2.0
|
| 48 |
+
self.opp_y = self.phys.table_height / 2.0
|
| 49 |
+
self.ego_vy = 0.0
|
| 50 |
+
self.opp_vy = 0.0
|
| 51 |
+
self.prev_ego_action = 0
|
| 52 |
+
|
| 53 |
+
self.ball_x = self.phys.table_width / 2.0
|
| 54 |
+
self.ball_y = self.phys.table_height / 2.0
|
| 55 |
+
|
| 56 |
+
if serve_direction is None:
|
| 57 |
+
serve_direction = 1 if self.rng.random() < 0.5 else -1
|
| 58 |
+
|
| 59 |
+
serve_angle = self.rng.uniform(-math.pi / 7.0, math.pi / 7.0)
|
| 60 |
+
speed = initial_speed or self.phys.ball_speed_initial
|
| 61 |
+
self.ball_vx = serve_direction * speed * math.cos(serve_angle)
|
| 62 |
+
self.ball_vy = speed * math.sin(serve_angle)
|
| 63 |
+
self.rally_count = 0
|
| 64 |
+
self.step_count = 0
|
| 65 |
+
|
| 66 |
+
return self.get_ego_observation()
|
| 67 |
+
|
| 68 |
+
def _get_action_velocity(self, action: int) -> float:
|
| 69 |
+
if action == 1:
|
| 70 |
+
return -self.phys.paddle_speed
|
| 71 |
+
elif action == 2:
|
| 72 |
+
return self.phys.paddle_speed
|
| 73 |
+
return 0.0
|
| 74 |
+
|
| 75 |
+
def physics_substep(self, ego_action: int, opp_action: int) -> Tuple[bool, Dict[str, Any]]:
|
| 76 |
+
info = {"winner": None}
|
| 77 |
+
done = False
|
| 78 |
+
|
| 79 |
+
prev_ego_y = self.ego_y
|
| 80 |
+
prev_opp_y = self.opp_y
|
| 81 |
+
|
| 82 |
+
ego_target_v = self._get_action_velocity(ego_action)
|
| 83 |
+
opp_target_v = self._get_action_velocity(opp_action)
|
| 84 |
+
|
| 85 |
+
alpha = self.phys.paddle_smoothing
|
| 86 |
+
self.ego_vy = alpha * self.ego_vy + (1.0 - alpha) * ego_target_v
|
| 87 |
+
self.opp_vy = alpha * self.opp_vy + (1.0 - alpha) * opp_target_v
|
| 88 |
+
|
| 89 |
+
ego_half_h = self.ego_paddle_h / 2.0
|
| 90 |
+
opp_half_h = self.opp_paddle_h / 2.0
|
| 91 |
+
|
| 92 |
+
self.ego_y = float(np.clip(self.ego_y + self.ego_vy, ego_half_h,
|
| 93 |
+
self.phys.table_height - ego_half_h))
|
| 94 |
+
self.opp_y = float(np.clip(self.opp_y + self.opp_vy, opp_half_h,
|
| 95 |
+
self.phys.table_height - opp_half_h))
|
| 96 |
+
|
| 97 |
+
prev_ball_x = self.ball_x
|
| 98 |
+
prev_ball_y = self.ball_y
|
| 99 |
+
r = self.phys.ball_radius
|
| 100 |
+
|
| 101 |
+
ego_paddle_x = self.phys.paddle_width
|
| 102 |
+
opp_paddle_x = self.phys.table_width - self.phys.paddle_width
|
| 103 |
+
ego_impact_plane = ego_paddle_x + r
|
| 104 |
+
opp_impact_plane = opp_paddle_x - r
|
| 105 |
+
|
| 106 |
+
next_ball_x = prev_ball_x + self.ball_vx
|
| 107 |
+
next_ball_y = prev_ball_y + self.ball_vy
|
| 108 |
+
|
| 109 |
+
hit_occurred = False
|
| 110 |
+
|
| 111 |
+
# Left (ego) paddle — continuous collision detection
|
| 112 |
+
if self.ball_vx < 0 and prev_ball_x >= ego_impact_plane and next_ball_x <= ego_impact_plane:
|
| 113 |
+
t = float(np.clip((prev_ball_x - ego_impact_plane) / max(1e-6, -self.ball_vx), 0.0, 1.0))
|
| 114 |
+
y_ball_at_impact = prev_ball_y + t * self.ball_vy
|
| 115 |
+
y_ego_at_impact = prev_ego_y + t * (self.ego_y - prev_ego_y)
|
| 116 |
+
|
| 117 |
+
if abs(y_ball_at_impact - y_ego_at_impact) <= (ego_half_h + r * 0.6):
|
| 118 |
+
hit_occurred = True
|
| 119 |
+
self.rally_count += 1
|
| 120 |
+
offset = float(np.clip((y_ball_at_impact - y_ego_at_impact) / ego_half_h, -1.0, 1.0))
|
| 121 |
+
bounce_angle = offset * (math.pi / 3.0)
|
| 122 |
+
current_speed = math.hypot(self.ball_vx, self.ball_vy)
|
| 123 |
+
new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max)
|
| 124 |
+
new_vx = new_speed * math.cos(bounce_angle)
|
| 125 |
+
new_vy = new_speed * math.sin(bounce_angle) + 0.25 * self.ego_vy
|
| 126 |
+
|
| 127 |
+
rem_dt = 1.0 - t
|
| 128 |
+
self.ball_x = ego_impact_plane + rem_dt * new_vx
|
| 129 |
+
self.ball_y = y_ball_at_impact + rem_dt * new_vy
|
| 130 |
+
self.ball_vx = new_vx
|
| 131 |
+
self.ball_vy = new_vy
|
| 132 |
+
|
| 133 |
+
# Right (opponent) paddle
|
| 134 |
+
elif self.ball_vx > 0 and prev_ball_x <= opp_impact_plane and next_ball_x >= opp_impact_plane:
|
| 135 |
+
t = float(np.clip((opp_impact_plane - prev_ball_x) / max(1e-6, self.ball_vx), 0.0, 1.0))
|
| 136 |
+
y_ball_at_impact = prev_ball_y + t * self.ball_vy
|
| 137 |
+
y_opp_at_impact = prev_opp_y + t * (self.opp_y - prev_opp_y)
|
| 138 |
+
|
| 139 |
+
if abs(y_ball_at_impact - y_opp_at_impact) <= (opp_half_h + r * 0.6):
|
| 140 |
+
hit_occurred = True
|
| 141 |
+
self.rally_count += 1
|
| 142 |
+
offset = float(np.clip((y_ball_at_impact - y_opp_at_impact) / opp_half_h, -1.0, 1.0))
|
| 143 |
+
bounce_angle = offset * (math.pi / 3.0)
|
| 144 |
+
current_speed = math.hypot(self.ball_vx, self.ball_vy)
|
| 145 |
+
new_speed = min(current_speed * self.phys.ball_acceleration, self.phys.ball_speed_max)
|
| 146 |
+
new_vx = -new_speed * math.cos(bounce_angle)
|
| 147 |
+
new_vy = new_speed * math.sin(bounce_angle) + 0.25 * self.opp_vy
|
| 148 |
+
|
| 149 |
+
rem_dt = 1.0 - t
|
| 150 |
+
self.ball_x = opp_impact_plane + rem_dt * new_vx
|
| 151 |
+
self.ball_y = y_ball_at_impact + rem_dt * new_vy
|
| 152 |
+
self.ball_vx = new_vx
|
| 153 |
+
self.ball_vy = new_vy
|
| 154 |
+
|
| 155 |
+
if not hit_occurred:
|
| 156 |
+
self.ball_x = next_ball_x
|
| 157 |
+
self.ball_y = next_ball_y
|
| 158 |
+
|
| 159 |
+
# Wall collisions
|
| 160 |
+
if self.ball_y - r <= 0:
|
| 161 |
+
self.ball_y = r + abs(r - self.ball_y)
|
| 162 |
+
self.ball_vy = abs(self.ball_vy)
|
| 163 |
+
elif self.ball_y + r >= self.phys.table_height:
|
| 164 |
+
self.ball_y = (self.phys.table_height - r) - abs(self.ball_y + r - self.phys.table_height)
|
| 165 |
+
self.ball_vy = -abs(self.ball_vy)
|
| 166 |
+
|
| 167 |
+
# Goal boundaries
|
| 168 |
+
if self.ball_x - r < 0:
|
| 169 |
+
done = True
|
| 170 |
+
info["winner"] = "opponent"
|
| 171 |
+
elif self.ball_x + r > self.phys.table_width:
|
| 172 |
+
done = True
|
| 173 |
+
info["winner"] = "ego"
|
| 174 |
+
|
| 175 |
+
self.prev_ego_action = ego_action
|
| 176 |
+
return done, info
|
| 177 |
+
|
| 178 |
+
def step(self, ego_action: int, opp_action: int) -> Tuple[np.ndarray, bool, Dict[str, Any]]:
|
| 179 |
+
self.step_count += 1
|
| 180 |
+
done = False
|
| 181 |
+
info = {"winner": None}
|
| 182 |
+
|
| 183 |
+
for _ in range(self.phys.frame_skip):
|
| 184 |
+
d, sub_info = self.physics_substep(ego_action, opp_action)
|
| 185 |
+
if d:
|
| 186 |
+
done = True
|
| 187 |
+
info = sub_info
|
| 188 |
+
break
|
| 189 |
+
|
| 190 |
+
if not done and self.step_count >= self.phys.max_rally_steps:
|
| 191 |
+
done = True
|
| 192 |
+
info["winner"] = "draw"
|
| 193 |
+
|
| 194 |
+
return self.get_ego_observation(), done, info
|
| 195 |
+
|
| 196 |
+
def calculate_intercept_y(self, target_x: float, ball_x: float, ball_y: float,
|
| 197 |
+
ball_vx: float, ball_vy: float) -> float:
|
| 198 |
+
if (target_x > ball_x and ball_vx <= 0) or (target_x < ball_x and ball_vx >= 0):
|
| 199 |
+
return self.phys.table_height / 2.0
|
| 200 |
+
|
| 201 |
+
bx, by = float(ball_x), float(ball_y)
|
| 202 |
+
bvx, bvy = float(ball_vx), float(ball_vy)
|
| 203 |
+
h = self.phys.table_height
|
| 204 |
+
r = self.phys.ball_radius
|
| 205 |
+
|
| 206 |
+
for _ in range(10):
|
| 207 |
+
dt_x = (target_x - bx) / bvx if bvx != 0 else float("inf")
|
| 208 |
+
if dt_x <= 0:
|
| 209 |
+
break
|
| 210 |
+
if bvy > 0:
|
| 211 |
+
dt_y = (h - r - by) / bvy
|
| 212 |
+
elif bvy < 0:
|
| 213 |
+
dt_y = (r - by) / bvy
|
| 214 |
+
else:
|
| 215 |
+
dt_y = float("inf")
|
| 216 |
+
|
| 217 |
+
if dt_x <= dt_y:
|
| 218 |
+
by += bvy * dt_x
|
| 219 |
+
break
|
| 220 |
+
else:
|
| 221 |
+
bx += bvx * dt_y
|
| 222 |
+
by += bvy * dt_y
|
| 223 |
+
bvy = -bvy
|
| 224 |
+
|
| 225 |
+
return float(np.clip(by, r, h - r))
|
| 226 |
+
|
| 227 |
+
def get_ego_observation(self) -> np.ndarray:
|
| 228 |
+
w, h = self.phys.table_width, self.phys.table_height
|
| 229 |
+
v_max = self.phys.ball_speed_max
|
| 230 |
+
pv_max = self.phys.paddle_speed
|
| 231 |
+
half_h = self.ego_paddle_h / 2.0
|
| 232 |
+
ego_x = self.phys.paddle_width
|
| 233 |
+
|
| 234 |
+
pred_intercept_y = self.calculate_intercept_y(ego_x, self.ball_x, self.ball_y,
|
| 235 |
+
self.ball_vx, self.ball_vy)
|
| 236 |
+
rel_pred_y = (pred_intercept_y - self.ego_y) / h
|
| 237 |
+
pred_norm_y = pred_intercept_y / h
|
| 238 |
+
|
| 239 |
+
opp_y_norm = self.opp_y / h
|
| 240 |
+
opp_open_top = (self.opp_y - half_h) / h
|
| 241 |
+
opp_open_bottom = (h - (self.opp_y + half_h)) / h
|
| 242 |
+
speed_norm = math.hypot(self.ball_vx, self.ball_vy) / v_max
|
| 243 |
+
|
| 244 |
+
return np.array([
|
| 245 |
+
(self.ball_y - self.ego_y) / h,
|
| 246 |
+
(self.ball_x - ego_x) / w,
|
| 247 |
+
self.ball_vx / v_max,
|
| 248 |
+
self.ball_vy / v_max,
|
| 249 |
+
self.ego_y / h,
|
| 250 |
+
self.ego_vy / pv_max,
|
| 251 |
+
(self.opp_y - self.ego_y) / h,
|
| 252 |
+
self.opp_vy / pv_max,
|
| 253 |
+
self.ball_y / h,
|
| 254 |
+
self.ball_x / w,
|
| 255 |
+
rel_pred_y,
|
| 256 |
+
pred_norm_y,
|
| 257 |
+
opp_y_norm,
|
| 258 |
+
opp_open_top,
|
| 259 |
+
opp_open_bottom,
|
| 260 |
+
speed_norm,
|
| 261 |
+
], dtype=np.float32)
|
| 262 |
+
|
| 263 |
+
def get_opp_observation(self) -> np.ndarray:
|
| 264 |
+
w, h = self.phys.table_width, self.phys.table_height
|
| 265 |
+
v_max = self.phys.ball_speed_max
|
| 266 |
+
pv_max = self.phys.paddle_speed
|
| 267 |
+
half_h = self.opp_paddle_h / 2.0
|
| 268 |
+
opp_x = self.phys.table_width - self.phys.paddle_width
|
| 269 |
+
|
| 270 |
+
pred_intercept_y = self.calculate_intercept_y(opp_x, self.ball_x, self.ball_y,
|
| 271 |
+
self.ball_vx, self.ball_vy)
|
| 272 |
+
rel_pred_y = (pred_intercept_y - self.opp_y) / h
|
| 273 |
+
pred_norm_y = pred_intercept_y / h
|
| 274 |
+
|
| 275 |
+
ego_y_norm = self.ego_y / h
|
| 276 |
+
ego_open_top = (self.ego_y - half_h) / h
|
| 277 |
+
ego_open_bottom = (h - (self.ego_y + half_h)) / h
|
| 278 |
+
speed_norm = math.hypot(self.ball_vx, self.ball_vy) / v_max
|
| 279 |
+
|
| 280 |
+
return np.array([
|
| 281 |
+
(self.ball_y - self.opp_y) / h,
|
| 282 |
+
(opp_x - self.ball_x) / w,
|
| 283 |
+
-self.ball_vx / v_max,
|
| 284 |
+
self.ball_vy / v_max,
|
| 285 |
+
self.opp_y / h,
|
| 286 |
+
self.opp_vy / pv_max,
|
| 287 |
+
(self.ego_y - self.opp_y) / h,
|
| 288 |
+
self.ego_vy / pv_max,
|
| 289 |
+
self.ball_y / h,
|
| 290 |
+
(w - self.ball_x) / w,
|
| 291 |
+
rel_pred_y,
|
| 292 |
+
pred_norm_y,
|
| 293 |
+
ego_y_norm,
|
| 294 |
+
ego_open_top,
|
| 295 |
+
ego_open_bottom,
|
| 296 |
+
speed_norm,
|
| 297 |
+
], dtype=np.float32)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ==================================================================================
|
| 301 |
+
# Baseline opponents (ported from the author's inference.py)
|
| 302 |
+
# ==================================================================================
|
| 303 |
+
|
| 304 |
+
def smooth_aim_action(current_y: float, target_y: float, prev_action: int,
|
| 305 |
+
deadzone: float = 6.0) -> int:
|
| 306 |
+
diff = target_y - current_y
|
| 307 |
+
if abs(diff) < deadzone:
|
| 308 |
+
return 0
|
| 309 |
+
return 2 if diff > 0 else 1
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
class RealisticHardOpponent:
|
| 313 |
+
def __init__(self, commit_x_ratio: float = 0.60, rng: Optional[random.Random] = None):
|
| 314 |
+
self.commit_x_ratio = commit_x_ratio
|
| 315 |
+
self.prev_action = 0
|
| 316 |
+
self.perceptual_noise = 0.0
|
| 317 |
+
self.rng = rng or random.Random()
|
| 318 |
+
|
| 319 |
+
def act(self, env: StandalonePongEnv) -> int:
|
| 320 |
+
if env.ball_vx <= 0:
|
| 321 |
+
target_y = env.phys.table_height / 2.0
|
| 322 |
+
self.perceptual_noise = self.rng.uniform(-12.0, 12.0)
|
| 323 |
+
elif env.ball_x < env.phys.table_width * self.commit_x_ratio:
|
| 324 |
+
target_y = env.phys.table_height / 2.0 + (env.ball_y - env.phys.table_height / 2.0) * 0.40
|
| 325 |
+
else:
|
| 326 |
+
target_x = env.phys.table_width - env.phys.paddle_width
|
| 327 |
+
exact_y = env.calculate_intercept_y(target_x, env.ball_x, env.ball_y,
|
| 328 |
+
env.ball_vx, env.ball_vy)
|
| 329 |
+
target_y = float(np.clip(exact_y + self.perceptual_noise, 8.0,
|
| 330 |
+
env.phys.table_height - 8.0))
|
| 331 |
+
|
| 332 |
+
action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=7.0)
|
| 333 |
+
self.prev_action = action
|
| 334 |
+
return action
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
class MediumOpponent:
|
| 338 |
+
def __init__(self, rng: Optional[random.Random] = None):
|
| 339 |
+
self.prev_action = 0
|
| 340 |
+
self.rng = rng or random.Random()
|
| 341 |
+
|
| 342 |
+
def act(self, env: StandalonePongEnv) -> int:
|
| 343 |
+
if env.ball_vx <= 0:
|
| 344 |
+
target_y = env.phys.table_height / 2.0
|
| 345 |
+
else:
|
| 346 |
+
dt = (env.phys.table_width - env.phys.paddle_width - env.ball_x) / max(1.0, env.ball_vx)
|
| 347 |
+
target_y = env.ball_y + env.ball_vy * dt
|
| 348 |
+
target_y = float(np.clip(target_y, 0, env.phys.table_height))
|
| 349 |
+
|
| 350 |
+
action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=14.0)
|
| 351 |
+
self.prev_action = action
|
| 352 |
+
return action
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
class EasyOpponent:
|
| 356 |
+
def __init__(self, rng: Optional[random.Random] = None):
|
| 357 |
+
self.prev_action = 0
|
| 358 |
+
self.rng = rng or random.Random()
|
| 359 |
+
|
| 360 |
+
def act(self, env: StandalonePongEnv) -> int:
|
| 361 |
+
if env.ball_vx <= 0 or env.ball_x < env.phys.table_width * 0.45:
|
| 362 |
+
target_y = env.phys.table_height / 2.0
|
| 363 |
+
else:
|
| 364 |
+
target_y = env.ball_y + self.rng.uniform(-30.0, 30.0)
|
| 365 |
+
|
| 366 |
+
action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=25.0)
|
| 367 |
+
self.prev_action = action
|
| 368 |
+
return action
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
class ImpossibleHardOpponent:
|
| 372 |
+
def __init__(self, rng: Optional[random.Random] = None):
|
| 373 |
+
self.prev_action = 0
|
| 374 |
+
self.rng = rng or random.Random()
|
| 375 |
+
|
| 376 |
+
def act(self, env: StandalonePongEnv) -> int:
|
| 377 |
+
if env.ball_vx <= 0:
|
| 378 |
+
target_y = env.phys.table_height / 2.0
|
| 379 |
+
else:
|
| 380 |
+
target_x = env.phys.table_width - env.phys.paddle_width
|
| 381 |
+
target_y = env.calculate_intercept_y(target_x, env.ball_x, env.ball_y,
|
| 382 |
+
env.ball_vx, env.ball_vy)
|
| 383 |
+
|
| 384 |
+
action = smooth_aim_action(env.opp_y, target_y, self.prev_action, deadzone=2.0)
|
| 385 |
+
self.prev_action = action
|
| 386 |
+
return action
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
class RandomOpponent:
|
| 390 |
+
def __init__(self, rng: Optional[random.Random] = None):
|
| 391 |
+
self.rng = rng or random.Random()
|
| 392 |
+
|
| 393 |
+
def act(self, env: StandalonePongEnv) -> int:
|
| 394 |
+
return self.rng.randint(0, 2)
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
OPPONENTS = {
|
| 398 |
+
"Realistic Hard": RealisticHardOpponent,
|
| 399 |
+
"Medium Logic": MediumOpponent,
|
| 400 |
+
"Easy Logic": EasyOpponent,
|
| 401 |
+
"Impossible Hard": ImpossibleHardOpponent,
|
| 402 |
+
"Random Agent": RandomOpponent,
|
| 403 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
transformers
|
| 3 |
+
numpy
|
| 4 |
+
pillow
|
| 5 |
+
imageio
|
| 6 |
+
imageio-ffmpeg
|