import os
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import RegularPolygon, FancyBboxPatch, FancyArrowPatch
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from gridiron import Play, Player, offense, defense, routes as r, show_animation, style, draw_marker
from gridiron.play import MARKER_R
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
FPS = 10
# Predict-the-play answers: collapsed under each drill on the web, collected after Connections in print
ANSWERS = []
def answer(md):
ANSWERS.append(md.strip())
if PDF:
print('\n*Answer: try it first, then see "Answers to Predict the play" at the end of the chapter.*\n')
else:
print('\n::: {.callout-tip title="Answer" collapse="true"}\n\n' + md.strip() + "\n\n:::\n")
# ---------------------------------------------------------------- alignments used all chapter
def nickel_vs_2x2(shell="two_high", safety_w=7.5):
"""11 personnel 2x2 against a nickel (4-2-5) defense. The Will apexes the left slot, the
corners play 7 yards off, and two-high safeties sit 13 deep a few yards outside the hashes."""
off = offense("gun_2x2")
dfn = defense("nickel", shell, off)
out = []
for p in dfn:
if p.key == "WILL":
p = p.moved(d=5.0, w=-6.0)
elif p.pos == "CB":
p = p.moved(d=7.0, w=p.w + (0.8 if p.w > 0 else -0.8), label="CB")
elif p.key in ("FS", "SS") and shell == "two_high":
p = p.moved(d=13.0, w=safety_w if p.w > 0 else -safety_w)
out.append(p)
return off, out
def protect(p, rushers=("SDE", "SDT", "WDT", "WDE")):
"""Five linemen set, four rushers engage them: enough to show it is a pass, no more."""
for k in ("LT", "LG", "C", "RG", "RT"):
p.block(k, pts=[(-1.2, 0.0)])
for k in rushers:
p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
def trips(detached=False):
"""11 personnel 3x1: X alone to the left; Z (#1), H (#2) and the tight end Y (#3) to the right.
attached: Y in-line as the end on the line (a "trey" set). detached: Y flexed off the ball as a
third slot and Z steps up to be the end on the line, so seven are still on the line."""
off = offense("gun_trips")
if detached: # a real third slot: about 4 yards outside the tackle, a step off the ball
off = [p.moved(d=-1.5, w=7.0) if p.key == "Y" else p.moved(d=-0.7) if p.key == "Z" else p
for p in off]
return off
def trips_defense(off, fs_w=-6.5):
"""Two-high nickel against 3x1. The backside corner presses X with a one-yard inside shade."""
dfn = defense("nickel", "two_high", off)
moves = {"FS": (12.0, fs_w), "SS": (12.0, 9.0), "LCB": (1.5, -14.0), "RCB": (7.0, 17.5),
"NB": (5.0, 10.5), "MIKE": (4.5, 3.0), "WILL": (4.5, -2.0)}
dfn = [p.moved(d=moves[p.key][0], w=moves[p.key][1]) if p.key in moves else p for p in dfn]
return [p.moved(label="CB") if p.pos == "CB" else p for p in dfn]
def number_receivers(p, d=-3.2, extra=None):
"""Label the trips receivers #1-#3 (outside in); `extra` adds a second label below, e.g. {"Y": "detached"}."""
for key, txt in (("Z", "#1"), ("H", "#2"), ("Y", "#3")):
pl = p.players[key]
p.note(txt, at=(pl.d + d, pl.w))
if extra and key in extra:
p.note(extra[key], at=(pl.d + d - 1.8, pl.w - 1.0))
def no_numbers(ax):
"""Remove the painted yard-line numbers (they sit where wide receivers and safeties stand)."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
FIELD_W = 160 / 3
def ruler(fld, play, widths=False, fs=6.5):
"""Depth labels (yards past the line of scrimmage) down the left edge of the field window, so
the faint 5-yard lines can be read as a scale. widths=True also marks where the hash marks and
the painted numbers are, along the bottom edge. Drawn on the Field's ax: gridiron has no ruler."""
ax = fld.ax
x_left = FIELD_W - fld.y1 + 0.35 # plot x of the window's left edge
for d in range(5, 41, 5):
y = play.los + d
if fld.x0 + 0.8 <= y <= fld.x1 - 0.8:
ax.text(x_left, y, f"{d}" + (" yd" if d == 5 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))
if widths:
y = fld.x0 + 0.9
for yb, txt in ((23.58, "hash"), (FIELD_W - 23.58, "hash"), (13.0, "numbers"), (FIELD_W - 13.0, "numbers")):
if fld.y0 + 1.5 <= yb <= fld.y1 - 1.5:
ax.text(FIELD_W - yb, y, txt, fontsize=fs, color=style.INK_2, ha="center", va="center",
zorder=12, style="italic",
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
# ---------------------------------------------------------------- two-time-state drawing
def ghost_marker(ax, x, y, label, ppy, color=style.DEFENSE):
"""A hollow, dashed defender: where he stood before the snap (the disguise)."""
ax.add_patch(RegularPolygon((x, y), 4, radius=MARKER_R * 1.25, orientation=np.pi / 4,
facecolor="none", edgecolor=color, lw=1.2, ls=(0, (2, 1.2)), zorder=6))
if label:
ax.text(x, y, label, ha="center", va="center", color=color, fontweight="bold", zorder=6,
fontsize=max(4.0, min(8.5, ppy * 0.75)))
def snapshot(play, t, ax=None, ghosts=(), ghost_t=None, lateral="full", window=(-8.5, 19.5),
width_in=6.5, notes=(), show_ruler=False):
"""Everyone where he is at time t; `ghosts` also keep a hollow marker where they stood at
`ghost_t` and a dashed trail from there, so the pre-snap picture survives in print."""
pos = {k: play.position(k, t) for k in play.players}
moved = [pl.moved(d=float(pos[k][0]), w=float(pos[k][1])) for k, pl in play.players.items()]
q = Play([m for m in moved if m.side == "off"], [m for m in moved if m.side == "def"],
los=play.los, ball_y=play.ball_y, to_go=play.to_go)
q.highlights = dict(play.highlights)
for n in notes:
q.note(*n)
fld = q.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False)
ax, ppy = fld.ax, fld.pts_per_yard()
no_numbers(ax)
if show_ruler:
ruler(fld, play, fs=5.5)
P = lambda d, w: fld.xy(*play.to_abs(d, w)) # noqa: E731
start = ghost_t if ghost_t is not None else play._compile()[1]
for k, pl in play.players.items():
if pl.side == "off" and pl.pos not in ("WR", "TE", "RB"):
continue
pts = np.array([play.position(k, tt) for tt in np.linspace(start, t, 30)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) < 0.3:
continue
xs, ys = P(pts[:, 0], pts[:, 1])
if pl.side == "off":
ax.plot(xs, ys, color=style.OFFENSE, lw=1.0, alpha=0.45, zorder=3)
else:
ax.plot(xs, ys, color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), zorder=3)
if ghost_t is not None and t > 0.05:
for k in ghosts:
x0, y0 = P(*play.position(k, ghost_t))
ghost_marker(ax, float(x0), float(y0), play.players[k].label, ppy)
bx, by = P(*play.ball_position(t))
for kind, _, t_rel in getattr(play, "ball_events", []):
if kind == "pass" and t > t_rel: # a thrown ball: draw its flight so far
sx, sy = P(*play.ball_position(t_rel))
ax.add_patch(FancyArrowPatch((float(sx), float(sy)), (float(bx), float(by)), arrowstyle="-|>",
mutation_scale=8, color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), zorder=9))
draw_marker(ax, float(bx), float(by), "ball", "", ppy)
return fld
def strip(play, times, labels, ghosts=(), ghost_t=None, lateral=(-21, 21), window=(-8.5, 19.5),
notes_at=None, width_in=6.5, ncols=2):
"""The print version of an animation: panels at chosen moments, ghosts kept on every
post-snap panel so 'what was shown' and 'what was played' sit side by side."""
n = len(times)
nrows = int(np.ceil(n / ncols))
pw = width_in / ncols
h = nrows * (pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0]) + 0.32)
fig, axes = plt.subplots(nrows, ncols, figsize=(width_in, h), squeeze=False)
for ax in axes.flat[n:]:
ax.axis("off")
for i, (ax, t, lab) in enumerate(zip(axes.flat, times, labels)):
snapshot(play, t, ax=ax, ghosts=ghosts, ghost_t=ghost_t, lateral=lateral, window=window,
notes=(notes_at or {}).get(i, ()), show_ruler=(i == 0))
ax.set_title(lab, fontsize=8.5, color=style.INK, loc="left")
fig.tight_layout(h_pad=0.6, w_pad=0.4)
h = [Patch(facecolor="none", edgecolor=style.DEFENSE, lw=1.2, ls=(0, (2, 1.2)),
label="Dashed outline: where he stood before the snap (the disguise)"),
Patch(facecolor=style.DEFENSE_TINT, edgecolor=style.DEFENSE, lw=1.3, label="Solid, filled: where he is now"),
Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="Defender's path"),
Line2D([], [], color=style.OFFENSE, lw=1.0, alpha=0.6, label="Receiver's path")]
fig.legend(handles=h, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=2, fontsize=7.5, frameon=False)
return fig
def pointer(fld, play, text, text_at, to, ha="center"):
"""A labelled arrow from a text box to a spot on the field (both in (d, w) yards)."""
tx, ty = fld.xy(*play.to_abs(*text_at))
px, py = fld.xy(*play.to_abs(*to))
fld.ax.annotate(text, xy=(px, py), xytext=(tx, ty), ha=ha, va="center", fontsize=7.5, color=style.INK,
zorder=12, arrowprops=dict(arrowstyle="-|>", color=style.INK_2, lw=0.9, shrinkB=7),
bbox=dict(boxstyle="round,pad=0.25", fc=style.SURFACE, ec=style.BASELINE, lw=0.6))
def divider(fld, play, text_l, text_r, d_top=22.0, d_bot=-7.0):
"""A dashed line down the ball, captioning the two halves of a split-field call."""
(x0, y0), (x1, y1) = fld.xy(*play.to_abs(d_bot, 0.0)), fld.xy(*play.to_abs(d_top, 0.0))
fld.ax.plot([x0, x1], [y0, y1], color=style.INK_2, lw=1.0, ls=(0, (6, 4)), zorder=2)
for txt, ww, ha in ((text_l, -5.0, "right"), (text_r, 4.0, "left")):
x, y = fld.xy(*play.to_abs(d_bot + 1.2, ww))
fld.ax.text(x, y, txt, ha=ha, va="center", fontsize=8, color=style.INK, fontweight="bold", zorder=9,
bbox=dict(boxstyle="round,pad=0.25", fc=style.SURFACE, ec=style.BASELINE, lw=0.6))