import os
import logging
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse, FancyArrowPatch, FancyBboxPatch, RegularPolygon, Patch
from matplotlib.lines import Line2D
import gridiron.play as gplay
from gridiron import Play, Player, Field, offense, defense, style, draw_marker, show_animation, routes as r
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
HALF = 80 / 3 # yards from the middle of the field to either sideline (26.7)
THIRD_W = HALF / 3 # where one deep third meets the next: 8.9 yards either side of the middle
HASH_W = 18.5 / 6 # NFL hash marks: 18 ft 6 in apart, so 3.1 yards either side of the middle
NUM_W = HALF - 12.0 # the painted numbers start 12 yards in from the sideline: 14.7 from the middle
DD, OD, GREY = style.DEFENSE_DARK, style.OFFENSE_DARK, style.MUTED
GREEN_TEXT = "#0f7a55" # a darker step of the green highlight, for text on the light field
LOS = 40
# gridiron's default ball speed (18 yd/s) is a conservative average; a firm NFL throw to a seam or a
# hitch covers 20-30 yards in about a second, so this chapter's simulated passes use 24 yd/s.
# (A runtime setting for this chapter only; gridiron's source is unchanged.)
gplay.BALL_SPEED = 24.0
# ---------------------------------------------------------------- players and alignments
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=key if label is None else label)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def line5():
"""The five offensive linemen, 1.6 yards apart (only the center is lettered)."""
return [O(k, k, -0.7, w, "C" if k == "C" else "") for k, w in
(("LT", -3.2), ("LG", -1.6), ("C", 0.0), ("RG", 1.6), ("RT", 3.2))]
def spread(rb_w=-1.5):
"""11 personnel, shotgun 2x2: X and Z on the line (with the five linemen: seven on the line),
H in the left slot and the tight end Y flexed into the right slot, both a step off the ball."""
return line5() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, rb_w, "R"),
O("X", "WR", -0.7, -15.0), O("H", "WR", -1.5, -9.0),
O("Y", "TE", -1.5, 9.0), O("Z", "WR", -0.7, 15.0)]
def front4():
return [D("WDE", "DE", 1.0, -5.6, "E"), D("WDT", "DT", 1.0, -0.8, "T"),
D("SDT", "DT", 1.0, 2.4, "T"), D("SDE", "DE", 1.0, 5.6, "E")]
LAB = {"MIKE": ("MIKE", "M"), "WILL": ("WILL", "W"), "NB": ("NB", "$"), "LCB": ("CB", "CB"),
"RCB": ("CB", "CB"), "FS": ("FS", "FS"), "SS": ("SS", "SS")}
# The Cover 3 look before the snap (one-high) and the landmarks the seven droppers reach after it.
C3_PRE = {"MIKE": (4.5, 1.8), "WILL": (4.5, -2.2), "NB": (5.0, -7.0), "LCB": (7.5, -16.3),
"RCB": (7.5, 16.3), "FS": (14.5, 0.0), "SS": (5.5, 6.9)}
C3_POST = {"MIKE": (9.0, 4.2), "WILL": (9.0, -4.2), "NB": (9.5, -12.5), "LCB": (18.0, -16.8),
"RCB": (18.0, 16.8), "FS": (17.5, 0.0), "SS": (9.5, 12.5)}
def nickel(spots):
"""4-2-5 nickel: the front four plus seven droppers at `spots` = {key: (d, w)}."""
return front4() + [D(k, LAB[k][0], *spots[k], LAB[k][1]) for k in spots]
def protect(p, rushers=("WDE", "WDT", "SDT", "SDE"), linemen=("LT", "LG", "C", "RG", "RT")):
"""Linemen set, four rushers engage them: enough to say 'pass', no more."""
for k in linemen:
if k in p.players:
p.block(k, pts=[(-1.2, 0.0)])
for k in rushers:
if k in p.players:
p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
# ---------------------------------------------------------------- drawing helpers
def P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def clean(ax):
"""Drop the painted yard numbers (they sit where receivers stand); clip text to the window."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
else:
t.set_clip_on(True)
def ruler(fld, p, fs=6.0, step=5, top=40):
"""Depth labels (yards past the line of scrimmage) just inside the left edge of the window."""
xl = (160 / 3) - fld.y1 + 0.4
for d in range(step, top + 1, step):
y = p.los + d
if fld.x0 + 1.5 <= y <= fld.x1 - 1.5:
fld.ax.text(xl, y, f"{d}" + (" yd" if d == step else ""), fontsize=fs, color=style.INK_2,
ha="left", va="center", zorder=12,
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.0, ha="center", weight="normal", box=True):
x, y = P(fld, p, d, w)
fld.ax.text(float(x), float(y), text, fontsize=fontsize, color=color, ha=ha, va="center", zorder=13,
fontweight=weight, linespacing=1.15,
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.9) if box else None)
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=7.0, ha="center"):
(xt, yt), (xp, yp) = P(fld, p, *text_at), P(fld, p, *target)
fld.ax.annotate(text, (float(xp), float(yp)), xytext=(float(xt), float(yt)), fontsize=fontsize,
color=color, ha=ha, va="center", zorder=13, linespacing=1.15,
arrowprops=dict(arrowstyle="-", color=color, lw=0.7, shrinkA=2, shrinkB=7),
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.9))
def zone_box(fld, p, d0, d1, w0, w1, label="", color=style.DEFENSE, alpha=0.12, fs=6.8, ls=(0, (3, 2)),
label_d=None):
"""A rounded, shaded rectangle from depth d0 to d1 and width w0 to w1 (yards), labelled near its top."""
(x0, y0), (x1, y1) = P(fld, p, d0, w0), P(fld, p, d1, w1)
lo_x, lo_y = float(min(x0, x1)), float(min(y0, y1))
for fc, ec, a in ((color, "none", alpha), ("none", color, 1.0)):
fld.ax.add_patch(FancyBboxPatch((lo_x, lo_y), float(abs(x1 - x0)), float(abs(y1 - y0)),
boxstyle="round,pad=0,rounding_size=1.2", fc=fc, alpha=a if fc != "none" else 0.8,
ec=ec, lw=0.8, ls=ls, zorder=1.5))
if label:
say(fld, p, label_d if label_d is not None else d1 - 1.0, (w0 + w1) / 2, label, color=style.INK,
fontsize=fs, box=False)
def path(fld, p, pts, color=style.DEFENSE, lw=1.4, dash=(0, (3, 2)), z=5, shrink=1.15):
"""A dashed arrow through absolute (d, w) points, stopping just short of the last one (the marker)."""
pts = [tuple(map(float, q)) for q in pts]
(a, b), (c, e) = pts[-2], pts[-1]
n = float(np.hypot(c - a, e - b))
if n > shrink + 0.2:
pts[-1] = (c - (c - a) / n * shrink, e - (e - b) / n * shrink)
xs, ys = P(fld, p, np.array([q[0] for q in pts]), np.array([q[1] for q in pts]))
fld.ax.plot(xs, ys, color=color, lw=lw, ls=dash, zorder=z, solid_capstyle="round")
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>",
mutation_scale=9, color=color, lw=lw, zorder=z))
def route_line(fld, p, pts, alpha=0.9, lw=1.5, color=style.OFFENSE, ls="-", z=4):
"""A receiver's route through absolute (d, w) points, with an arrowhead (alpha < 1 for 'faint')."""
xs, ys = P(fld, p, np.array([float(q[0]) for q in pts]), np.array([float(q[1]) for q in pts]))
fld.ax.plot(xs, ys, color=color, lw=lw, alpha=alpha, zorder=z, ls=ls, solid_capstyle="round")
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=9,
color=color, lw=lw, alpha=alpha, zorder=z))
def ghost(fld, p, d, w, label="", color=style.DEFENSE):
"""A hollow, dashed defender: where he stood before he moved."""
x, y = P(fld, p, d, w)
ppy = fld.pts_per_yard()
fld.ax.add_patch(RegularPolygon((float(x), float(y)), 4, radius=gplay.MARKER_R * 1.25, orientation=np.pi / 4,
fc="none", ec=color, lw=1.1, ls=(0, (2, 1.2)), zorder=6))
if label:
fld.ax.text(float(x), float(y), label, ha="center", va="center", color=color, fontweight="bold",
zorder=6, fontsize=max(4.0, min(8.5, ppy * 0.75)))
def hole(fld, p, d, w, wd, dd, num, text=None, text_at=None, fs=6.6):
"""A green dashed ellipse marking a soft spot, with a numbered tag (and optional caption)."""
x, y = P(fld, p, d, w)
fld.ax.add_patch(Ellipse((float(x), float(y)), wd, dd, fc=style.THIRD, alpha=0.16, ec=style.THIRD,
lw=1.3, ls=(0, (3, 1.5)), zorder=2.5))
say(fld, p, d, w, num, color=GREEN_TEXT, fontsize=8.5, weight="bold", box=False)
if text:
say(fld, p, *text_at, text, color=GREEN_TEXT, fontsize=fs)
def panel_title(ax, text, fs=8.5):
ax.set_title(text, fontsize=fs, loc="left", color=style.INK, fontweight="bold")
def draw(p, ax=None, lateral="full", window=(-8.5, 22), width_in=6.5, show_ruler=True, rfs=6.0):
fld = p.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False)
clean(fld.ax)
if show_ruler:
ruler(fld, p, fs=rfs)
return fld
LEGEND = {
"route": Line2D([], [], color=style.OFFENSE, lw=1.8, label="route"),
"drop": Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="defender's movement"),
"ghost": Patch(fc="none", ec=style.DEFENSE, lw=1.1, ls=(0, (2, 1.2)), label="where he lined up"),
"ring": Line2D([], [], marker="o", ls="", ms=11, mfc="none", mec=style.THIRD, mew=2, label="watch him"),
"zone": Patch(fc=style.DEFENSE, alpha=0.18, ec=style.DEFENSE, ls=(0, (3, 2)), label="his zone"),
"hole": Patch(fc=style.THIRD, alpha=0.25, ec=style.THIRD, ls=(0, (3, 1.5)), label="soft spot"),
"pass": Line2D([], [], color=style.BALL, lw=1.3, ls=(0, (2, 2)), label="the throw"),
}
def field_legend(ax, keys, fs=7.0, ncol=None):
hs = [LEGEND[k] for k in keys]
ax.legend(handles=hs, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=ncol or min(len(hs), 5),
fontsize=fs, frameon=False, handlelength=2.0, columnspacing=1.2, borderaxespad=0.2)
def two_state(off, pre, post, via=None, rings=(), ax=None, lateral="full", window=(-8.5, 27.0),
width_in=6.5, rfs=6.0, rush=True, keep=()):
"""The diagram style for drops and rotations: every defender drawn where he ends up, a dashed
outline where he lined up (the pre-snap picture) and a dashed path between them.
via = {key: [(d, w), ...]} bends a path through intermediate points."""
p = Play(off, nickel(post), los=LOS, to_go=None)
if rush:
protect(p)
p.highlight(*rings)
fld = draw(p, ax=ax, lateral=lateral, window=window, width_in=width_in, rfs=rfs)
for k, a in pre.items():
b = post.get(k, a)
if np.hypot(b[0] - a[0], b[1] - a[1]) > 0.8 or k in keep:
ghost(fld, p, *a, LAB[k][1])
path(fld, p, [a] + list((via or {}).get(k, [])) + [b])
return fld, p
def deep_thirds(fld, p, d0=15.0, d1=26.5, labels=("deep ⅓", "deep middle ⅓", "deep ⅓"), label_d=None,
fs=6.8):
edges = [-HALF, -THIRD_W, THIRD_W, HALF]
for i, (a, b) in enumerate(zip(edges[:-1], edges[1:])):
zone_box(fld, p, d0, d1, a + 0.6, b - 0.6, labels[i], alpha=0.07 if i % 2 == 0 else 0.14, fs=fs,
label_d=label_d)
# ---------------------------------------------------------------- moments of a simulated play
def set_track(p, key, rows):
"""Hand-made keyframes [(t, d, w), ...] for one player (zone reads and pattern-match carries
that gridiron's straight-line drop() and man() can't express). Call after every other action."""
kf, t0, t1 = p._compile()
kf = dict(kf)
q = p.players[key]
rows = [(min(t0, -1.0), q.d, q.w), (0.0, q.d, q.w)] + [tuple(x) for x in rows]
kf[key] = np.array(rows, dtype=float)
p._compiled = (kf, t0, max(t1, rows[-1][0]))
def shadow(p, key, target, off, lag=0.3, t_on=0.9, t_from=0.0, start=None, t_end=3.6):
"""Keyframes for a defender who leaves `start` (or his alignment) at t_from and settles
`off` = (down, right) yards from where `target` was `lag` seconds earlier, fully by t_on."""
q = p.players[key]
s = np.array(start if start is not None else (q.d, q.w), dtype=float)
rows = []
for tt in np.round(np.arange(t_from + 0.05, t_end + 1e-9, 0.05), 3):
f = min(1.0, max(0.0, (tt - t_from) / max(1e-6, t_on - t_from)))
f = f * f * (3 - 2 * f)
tgt = p.position(target, max(0.0, tt - lag)) + np.array(off)
rows.append((tt, *(s * (1 - f) + tgt * f)))
return rows
def moment(ax, p, t, lateral, window, rings=(), throw_t=None):
"""Everyone at time t, with faint trails of where they have been and the ball's flight so far."""
ylo, yhi = sorted((p.ball_y - lateral[0], p.ball_y - lateral[1]))
fld = Field(ax=ax, orient="vertical", los=p.los, window=window, numbers=False, lateral=(ylo, yhi),
to_go=None)
ppy = fld.pts_per_yard()
inside = lambda d, w: (window[0] + 0.6 <= d <= window[1] - 0.6) and (lateral[0] + 0.6 <= w <= lateral[1] - 0.6) # noqa: E731
for k, q in p.players.items():
if q.pos in ("LT", "LG", "C", "RG", "RT", "DE", "DT"):
continue
pts = np.array([p.position(k, tt) for tt in np.linspace(0.0, max(t, 0.0), 40)])
if t > 0 and np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) > 0.3:
xs, ys = P(fld, p, pts[:, 0], pts[:, 1])
ax.plot(xs, ys, color=style.OFFENSE if q.side == "off" else style.DEFENSE,
lw=1.0, alpha=0.55, zorder=3, ls="-" if q.side == "off" else (0, (3, 2)))
if throw_t is not None and t > throw_t:
b = np.array([p.ball_position(tt) for tt in np.linspace(throw_t, t, 25)])
xs, ys = P(fld, p, b[:, 0], b[:, 1])
ax.plot(xs, ys, color=style.BALL, lw=1.3, ls=(0, (2, 1.5)), zorder=9)
for k, q in p.players.items():
d, w = p.position(k, t)
if not inside(d, w):
continue
x, y = P(fld, p, d, w)
draw_marker(ax, float(x), float(y), q.side, q.label, ppy, ring=style.THIRD if k in rings else None)
if t > 0.05:
bd, bw = p.ball_position(t)
if inside(bd, bw):
bx, by = P(fld, p, *((bd, bw - 1.0) if t <= (throw_t or 99) else (bd + 1.2, bw)))
ax.add_patch(Ellipse((float(bx), float(by)), 1.0, 0.62, fc=style.BALL, ec="white", lw=0.5, zorder=11))
return fld
def arrival(p, key, t_throw):
"""When the thrown ball reaches its receiver (first moment it is within half a yard of him)."""
for tt in np.arange(t_throw + 0.05, t_throw + 4.0, 0.02):
if np.linalg.norm(p.ball_position(tt) - p.position(key, tt)) < 0.5:
return float(round(tt, 2))
return t_throw + 1.5
def tag(fld, text, fs=6.6, loc="bl"):
"""A caption tucked into a corner of a frame panel."""
x, ha = (0.03, "left") if loc[1] == "l" else (0.97, "right")
y, va = (0.03, "bottom") if loc[0] == "b" else (0.97, "top")
fld.ax.text(x, y, text, transform=fld.ax.transAxes, ha=ha, va=va, fontsize=fs, color=style.INK, zorder=15,
linespacing=1.15, bbox=dict(boxstyle="round,pad=0.25", fc=style.SURFACE, ec=style.BASELINE, lw=0.5))
def strip(p, times, titles, tags, lateral, window, rings=(), throw_t=None, width_in=6.6, ncols=2,
tag_loc="bl", fs=6.4, depth=True, extra=None):
"""The print version of an animation: one panel per moment, with trails and a caption in each.
depth=True adds the yard ruler; extra(fld, i, t) draws panel-specific annotations."""
n = len(times)
nrows = int(np.ceil(n / ncols))
pw = width_in / ncols
span_w, span_d = abs(lateral[1] - lateral[0]), window[1] - window[0]
fig, axes = plt.subplots(nrows, ncols, figsize=(width_in, nrows * (pw * span_d / span_w + 0.3)), squeeze=False)
for ax in axes.flat[n:]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
fld = moment(ax, p, t, lateral, window, rings=rings, throw_t=throw_t)
if depth:
ruler(fld, p, fs=5.2)
if extra is not None:
extra(fld, i, t)
panel_title(ax, titles[i], fs=7.8)
if tags[i]:
tag(fld, tags[i], fs=fs, loc=tag_loc if isinstance(tag_loc, str) else tag_loc[i])
fig.tight_layout(h_pad=0.7, w_pad=0.5)
return fig