diff --git a/README.md b/README.md
index c845403..400fc0b 100644
--- a/README.md
+++ b/README.md
@@ -8,15 +8,15 @@ pixel explosions and a spinning radar. Runs with GitHub Actions.
**Lincoln — default carrier**
-
-
+
+
**Classic — original carrier**
-
-
+
+
Choose `carrier: 'lincoln'` (default) or `carrier: 'classic'` in your workflow.
@@ -26,7 +26,7 @@ Choose `water: 'ocean'` (default) for the flowing ocean surface and wake, or
`water: 'original'` for the original small wave patches. This works with either
carrier. For the CLI, use `--water ocean` or `--water original`.
-Both demos use **56 synthetic commits across 14 active days**. Live mode uses your
+Both demos show **three jets** and **56 synthetic commits across 14 active days**. Live mode uses your
actual data. **One attack clears one day cell**, regardless of that day's count.
The jet returns to land, the calendar rebuilds, and the animation loops.
@@ -55,8 +55,11 @@ smooth curves at a steady cruise speed and fires while moving. The default is
Set `jets: '2'` or `jets: '3'` to launch multiple jets and clear busy calendars
faster. The default is `jets: '1'`. Jets share the day cells, so each active day
-is still attacked exactly once. Launches are staggered and the calendar resets
-after every jet lands. Small calendars may take a similar time because launch
+is still attacked exactly once. Jets line up visibly on the runway, launch one
+at a time and land in order, returning to their original runway spots. Waiting
+jets remain visible before takeoff and after landing. Launch timing keeps the
+runway clear for each arrival. The calendar resets after every jet lands.
+Small calendars may take a similar time because launch
and return flights still need to finish. For the CLI, use `--jets 3`.
GIFs over **25 MB** are automatically retried at a lower resolution, then lower
diff --git a/examples/demo-classic/git-jet.gif b/examples/demo-classic/git-jet.gif
index 379784b..da18bd6 100644
Binary files a/examples/demo-classic/git-jet.gif and b/examples/demo-classic/git-jet.gif differ
diff --git a/examples/demo-classic/git-jet.png b/examples/demo-classic/git-jet.png
index 7d8e3e8..45df11a 100644
Binary files a/examples/demo-classic/git-jet.png and b/examples/demo-classic/git-jet.png differ
diff --git a/examples/demo-classic/report.json b/examples/demo-classic/report.json
index 0bf45b9..f0360b3 100644
--- a/examples/demo-classic/report.json
+++ b/examples/demo-classic/report.json
@@ -9,7 +9,7 @@
"active_days": 14,
"attacks": 14,
"attack_mode": "day-cell",
- "jets": 1,
+ "jets": 3,
"carrier": "classic",
"water": "ocean",
"seed": "git-jet-loop",
@@ -17,17 +17,39 @@
{
"jet": 1,
"seed": "git-jet-loop",
- "attacks": 14,
+ "attacks": 5,
"launch_frame": 8,
- "landed_frame": 480
+ "landed_frame": 464
+ },
+ {
+ "jet": 2,
+ "seed": "c00aa13156cdeac3fb98d0a11caf2c029fe45633b0fc5c66151bd6697d2bd51b",
+ "attacks": 5,
+ "launch_frame": 72,
+ "landed_frame": 533
+ },
+ {
+ "jet": 3,
+ "seed": "cfeb2707ba3fdf21e2c2ba11b9914dc444d464190efe3c955fe1f6578640abfd",
+ "attacks": 4,
+ "launch_frame": 149,
+ "landed_frame": 562
}
],
"maneuvers": [
{
"kind": "loop",
"direction": -1,
- "start_frame": 244,
- "end_frame": 289
+ "start_frame": 209,
+ "end_frame": 253,
+ "jet": 1
+ },
+ {
+ "kind": "loop",
+ "direction": -1,
+ "start_frame": 285,
+ "end_frame": 331,
+ "jet": 2
}
],
"width": 960,
@@ -36,12 +58,12 @@
"requested_scale": 2,
"requested_fps": 20,
"auto_compressed": false,
- "frames": 560,
+ "frames": 640,
"fps": 20,
- "duration_seconds": 28.0,
+ "duration_seconds": 32.0,
"transparent": true,
"decoded_frames_match": true,
- "gif_bytes": 14334512,
+ "gif_bytes": 16526574,
"targets": [
{
"date": "2026-10-18",
@@ -49,125 +71,125 @@
"commits": 6,
"column": 50,
"row": 0,
- "fire_frame": 189,
- "hit_frame": 196
+ "fire_frame": 154,
+ "hit_frame": 161
},
{
- "date": "2026-10-30",
+ "date": "2026-08-03",
"jet": 1,
- "commits": 7,
- "column": 51,
- "row": 5,
- "fire_frame": 197,
- "hit_frame": 203
+ "commits": 3,
+ "column": 39,
+ "row": 1,
+ "fire_frame": 181,
+ "hit_frame": 187
},
{
- "date": "2026-09-30",
+ "date": "2026-05-10",
"jet": 1,
- "commits": 5,
- "column": 47,
- "row": 3,
- "fire_frame": 201,
- "hit_frame": 207
+ "commits": 7,
+ "column": 27,
+ "row": 0,
+ "fire_frame": 236,
+ "hit_frame": 242
+ },
+ {
+ "date": "2026-10-30",
+ "jet": 2,
+ "commits": 7,
+ "column": 51,
+ "row": 5,
+ "fire_frame": 240,
+ "hit_frame": 246
},
{
"date": "2026-09-01",
- "jet": 1,
+ "jet": 2,
"commits": 4,
"column": 43,
"row": 2,
- "fire_frame": 208,
- "hit_frame": 214
+ "fire_frame": 253,
+ "hit_frame": 259
},
{
- "date": "2026-08-03",
- "jet": 1,
- "commits": 3,
- "column": 39,
- "row": 1,
- "fire_frame": 216,
- "hit_frame": 222
- },
- {
- "date": "2026-07-02",
+ "date": "2026-02-04",
"jet": 1,
- "commits": 2,
- "column": 34,
- "row": 4,
- "fire_frame": 222,
- "hit_frame": 227
+ "commits": 4,
+ "column": 13,
+ "row": 3,
+ "fire_frame": 266,
+ "hit_frame": 272
},
{
"date": "2026-04-12",
- "jet": 1,
+ "jet": 3,
"commits": 6,
"column": 23,
"row": 0,
- "fire_frame": 241,
- "hit_frame": 248
+ "fire_frame": 271,
+ "hit_frame": 278
},
{
- "date": "2026-03-16",
+ "date": "2025-11-16",
"jet": 1,
+ "commits": 1,
+ "column": 2,
+ "row": 0,
+ "fire_frame": 287,
+ "hit_frame": 294
+ },
+ {
+ "date": "2026-03-16",
+ "jet": 2,
"commits": 5,
"column": 19,
"row": 1,
- "fire_frame": 247,
- "hit_frame": 253
- },
- {
- "date": "2026-05-10",
- "jet": 1,
- "commits": 7,
- "column": 27,
- "row": 0,
- "fire_frame": 271,
- "hit_frame": 277
+ "fire_frame": 291,
+ "hit_frame": 297
},
{
"date": "2026-06-03",
- "jet": 1,
+ "jet": 2,
"commits": 1,
"column": 30,
"row": 3,
- "fire_frame": 277,
- "hit_frame": 283
+ "fire_frame": 319,
+ "hit_frame": 325
},
{
- "date": "2026-02-04",
- "jet": 1,
- "commits": 4,
- "column": 13,
+ "date": "2026-09-30",
+ "jet": 3,
+ "commits": 5,
+ "column": 47,
"row": 3,
- "fire_frame": 301,
- "hit_frame": 307
+ "fire_frame": 337,
+ "hit_frame": 343
},
{
- "date": "2026-01-13",
- "jet": 1,
- "commits": 3,
- "column": 10,
- "row": 2,
- "fire_frame": 307,
- "hit_frame": 313
+ "date": "2026-07-02",
+ "jet": 3,
+ "commits": 2,
+ "column": 34,
+ "row": 4,
+ "fire_frame": 358,
+ "hit_frame": 364
},
{
"date": "2025-12-16",
- "jet": 1,
+ "jet": 2,
"commits": 2,
"column": 6,
"row": 2,
- "fire_frame": 313,
- "hit_frame": 319
+ "fire_frame": 360,
+ "hit_frame": 366
},
{
- "date": "2025-11-16",
- "jet": 1,
- "commits": 1,
- "column": 2,
- "row": 0,
- "fire_frame": 322,
- "hit_frame": 329
+ "date": "2026-01-13",
+ "jet": 3,
+ "commits": 3,
+ "column": 10,
+ "row": 2,
+ "fire_frame": 398,
+ "hit_frame": 404
}
]
}
diff --git a/examples/demo/git-jet.gif b/examples/demo/git-jet.gif
index 98534dd..49346b3 100644
Binary files a/examples/demo/git-jet.gif and b/examples/demo/git-jet.gif differ
diff --git a/examples/demo/git-jet.png b/examples/demo/git-jet.png
index 84fbeac..9124a39 100644
Binary files a/examples/demo/git-jet.png and b/examples/demo/git-jet.png differ
diff --git a/examples/demo/report.json b/examples/demo/report.json
index de400ac..6a6387e 100644
--- a/examples/demo/report.json
+++ b/examples/demo/report.json
@@ -9,7 +9,7 @@
"active_days": 14,
"attacks": 14,
"attack_mode": "day-cell",
- "jets": 1,
+ "jets": 3,
"carrier": "lincoln",
"water": "ocean",
"seed": "git-jet-loop",
@@ -17,17 +17,39 @@
{
"jet": 1,
"seed": "git-jet-loop",
- "attacks": 14,
+ "attacks": 5,
"launch_frame": 8,
"landed_frame": 405
+ },
+ {
+ "jet": 2,
+ "seed": "c00aa13156cdeac3fb98d0a11caf2c029fe45633b0fc5c66151bd6697d2bd51b",
+ "attacks": 5,
+ "launch_frame": 35,
+ "landed_frame": 435
+ },
+ {
+ "jet": 3,
+ "seed": "cfeb2707ba3fdf21e2c2ba11b9914dc444d464190efe3c955fe1f6578640abfd",
+ "attacks": 4,
+ "launch_frame": 101,
+ "landed_frame": 452
}
],
"maneuvers": [
{
"kind": "loop",
"direction": -1,
- "start_frame": 160,
- "end_frame": 205
+ "start_frame": 148,
+ "end_frame": 193,
+ "jet": 1
+ },
+ {
+ "kind": "loop",
+ "direction": -1,
+ "start_frame": 185,
+ "end_frame": 232,
+ "jet": 2
}
],
"width": 960,
@@ -41,7 +63,7 @@
"duration_seconds": 24.0,
"transparent": true,
"decoded_frames_match": true,
- "gif_bytes": 13041689,
+ "gif_bytes": 13681523,
"targets": [
{
"date": "2026-10-18",
@@ -49,89 +71,62 @@
"commits": 6,
"column": 50,
"row": 0,
- "fire_frame": 106,
- "hit_frame": 113
+ "fire_frame": 94,
+ "hit_frame": 101
},
{
- "date": "2026-10-30",
+ "date": "2026-08-03",
"jet": 1,
+ "commits": 3,
+ "column": 39,
+ "row": 1,
+ "fire_frame": 120,
+ "hit_frame": 126
+ },
+ {
+ "date": "2026-10-30",
+ "jet": 2,
"commits": 7,
"column": 51,
"row": 5,
- "fire_frame": 113,
- "hit_frame": 119
- },
- {
- "date": "2026-09-30",
- "jet": 1,
- "commits": 5,
- "column": 47,
- "row": 3,
- "fire_frame": 117,
- "hit_frame": 123
+ "fire_frame": 140,
+ "hit_frame": 146
},
{
"date": "2026-09-01",
- "jet": 1,
+ "jet": 2,
"commits": 4,
"column": 43,
"row": 2,
- "fire_frame": 124,
- "hit_frame": 130
- },
- {
- "date": "2026-08-03",
- "jet": 1,
- "commits": 3,
- "column": 39,
- "row": 1,
- "fire_frame": 132,
- "hit_frame": 138
- },
- {
- "date": "2026-07-02",
- "jet": 1,
- "commits": 2,
- "column": 34,
- "row": 4,
- "fire_frame": 138,
- "hit_frame": 144
+ "fire_frame": 153,
+ "hit_frame": 159
},
{
"date": "2026-06-03",
- "jet": 1,
+ "jet": 2,
"commits": 1,
"column": 30,
"row": 3,
- "fire_frame": 145,
- "hit_frame": 151
+ "fire_frame": 173,
+ "hit_frame": 179
},
{
- "date": "2026-04-12",
+ "date": "2026-05-10",
"jet": 1,
- "commits": 6,
- "column": 23,
+ "commits": 7,
+ "column": 27,
"row": 0,
- "fire_frame": 157,
- "hit_frame": 164
+ "fire_frame": 176,
+ "hit_frame": 182
},
{
"date": "2026-03-16",
- "jet": 1,
+ "jet": 2,
"commits": 5,
"column": 19,
"row": 1,
- "fire_frame": 164,
- "hit_frame": 170
- },
- {
- "date": "2026-05-10",
- "jet": 1,
- "commits": 7,
- "column": 27,
- "row": 0,
- "fire_frame": 188,
- "hit_frame": 194
+ "fire_frame": 192,
+ "hit_frame": 198
},
{
"date": "2026-02-04",
@@ -139,35 +134,62 @@
"commits": 4,
"column": 13,
"row": 3,
- "fire_frame": 217,
- "hit_frame": 223
+ "fire_frame": 205,
+ "hit_frame": 211
},
{
- "date": "2026-01-13",
+ "date": "2025-11-16",
"jet": 1,
- "commits": 3,
- "column": 10,
- "row": 2,
- "fire_frame": 223,
- "hit_frame": 229
+ "commits": 1,
+ "column": 2,
+ "row": 0,
+ "fire_frame": 226,
+ "hit_frame": 233
+ },
+ {
+ "date": "2026-09-30",
+ "jet": 3,
+ "commits": 5,
+ "column": 47,
+ "row": 3,
+ "fire_frame": 227,
+ "hit_frame": 233
+ },
+ {
+ "date": "2026-07-02",
+ "jet": 3,
+ "commits": 2,
+ "column": 34,
+ "row": 4,
+ "fire_frame": 247,
+ "hit_frame": 253
},
{
"date": "2025-12-16",
- "jet": 1,
+ "jet": 2,
"commits": 2,
"column": 6,
"row": 2,
- "fire_frame": 229,
- "hit_frame": 235
+ "fire_frame": 260,
+ "hit_frame": 266
},
{
- "date": "2025-11-16",
- "jet": 1,
- "commits": 1,
- "column": 2,
+ "date": "2026-01-13",
+ "jet": 3,
+ "commits": 3,
+ "column": 10,
+ "row": 2,
+ "fire_frame": 288,
+ "hit_frame": 294
+ },
+ {
+ "date": "2026-04-12",
+ "jet": 3,
+ "commits": 6,
+ "column": 23,
"row": 0,
- "fire_frame": 238,
- "hit_frame": 245
+ "fire_frame": 349,
+ "hit_frame": 356
}
]
}
diff --git a/scripts/preview_runway.py b/scripts/preview_runway.py
new file mode 100644
index 0000000..0138be4
--- /dev/null
+++ b/scripts/preview_runway.py
@@ -0,0 +1,51 @@
+"""Render complete three-jet launch/landing loops and a scene contact sheet."""
+
+from pathlib import Path
+
+from PIL import Image, ImageDraw
+
+from git_jet.calendar import Calendar
+from git_jet.export import Options, render_assets
+from git_jet.render import Renderer
+
+
+def main():
+ root = Path(__file__).resolve().parents[1]
+ calendar = Calendar.read(root / "examples/demo/calendar.json")
+ for carrier in ("lincoln", "classic"):
+ output = root / "out/runway-preview" / carrier
+ report = render_assets(
+ calendar,
+ output,
+ Options(),
+ jets=3,
+ carrier=carrier,
+ seed="git-jet-loop",
+ source="demo",
+ )
+ renderer = Renderer(
+ calendar, fps=report["fps"], jets=3, carrier=carrier, seed=report["seed"]
+ )
+ first, last = renderer.flights[0], renderer.flights[-1]
+ scenes = (
+ ("Queued", 0),
+ ("First takeoff", first.launch_end),
+ ("All launched", last.launch_end),
+ ("First touchdown", first.touchdown),
+ ("First parked", first.landed),
+ ("All parked", renderer.flight.landed),
+ )
+ sheet = Image.new("RGB", (1440, 640), "#0d1117")
+ for index, (label, frame) in enumerate(scenes):
+ panel = Image.new("RGBA", (480, 320), "#0d1117")
+ panel.alpha_composite(renderer.frame(frame))
+ ImageDraw.Draw(panel).text(
+ (8, 8), f"{label} — {frame / report['fps']:.1f}s", fill="white"
+ )
+ sheet.paste(panel.convert("RGB"), ((index % 3) * 480, (index // 3) * 320))
+ sheet.save(output / "runway-contact-sheet.png")
+ print(f"{carrier}: {report['duration_seconds']}s, {report['gif_bytes']} bytes, {output}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/render_demos.py b/scripts/render_demos.py
index 6d1bf41..994404f 100644
--- a/scripts/render_demos.py
+++ b/scripts/render_demos.py
@@ -14,6 +14,7 @@ def main():
calendar,
root / "examples" / folder,
Options(),
+ jets=3,
carrier=carrier,
seed="git-jet-loop",
source="demo",
diff --git a/src/git_jet/art.py b/src/git_jet/art.py
index e5f849a..99f4e96 100644
--- a/src/git_jet/art.py
+++ b/src/git_jet/art.py
@@ -29,7 +29,9 @@ def validate_carrier(carrier):
def carrier_launch_points(carrier=DEFAULT_CARRIER):
validate_carrier(carrier)
if carrier == "classic":
- return (224, 33, 28), (-8, 32, 28)
+ # Keep the 55-pixel landing roll on the deck, with room for a queue
+ # on either side of the central launch/parking position.
+ return (168, 33, 28), (-8, 32, 28)
from .lincoln import flight_anchors
return flight_anchors()
diff --git a/src/git_jet/flight.py b/src/git_jet/flight.py
index 10c3c1b..2f20ade 100644
--- a/src/git_jet/flight.py
+++ b/src/git_jet/flight.py
@@ -17,6 +17,7 @@
GRID_X, GRID_Y, GRID_STEP = 28, 237, 8
HEADING_STEPS = 64
CRUISE_SPEED = 100.0 # Logical pixels per second, independent of export FPS.
+RUNWAY_SPACING = 32.0
def unit(vector):
@@ -250,6 +251,7 @@ def __init__(
delay=0,
jet_id=0,
carrier=DEFAULT_CARRIER,
+ deck_offset=0,
):
self.fps = fps
deck_point, bow_point = carrier_launch_points(carrier)
@@ -261,8 +263,10 @@ def __init__(
height = rng.uniform(68, 76)
sx, sy = project(*deck_point)
bx, by = project(*bow_point)
- self.deck, self.bow = (65 + sx, 12 + sy), (65 + bx, 12 + by)
+ base = (65 + sx, 12 + sy)
+ self.bow = (65 + bx, 12 + by)
self.runway = unit((bx - sx, by - sy))
+ self.deck = tuple(p + v * deck_offset for p, v in zip(base, self.runway, strict=True))
self.takeoff_heading = heading(self.runway)
departure_turn = turn_points(self.runway, (1, 0))
factor = (top - self.bow[1]) / departure_turn[-1][1]
@@ -297,18 +301,21 @@ def __init__(
for lap in range(laps)
for m in self.course.maneuvers
]
- approach = tuple(p - v * 55 for p, v in zip(self.deck, self.runway, strict=True))
- corner = turn_points((1, 0), (0, -1))
- factor = -(top - 137) / corner[-1][1]
- corner_x = 442 - (top - 137)
- corner = [(corner_x + x * factor, top + y * factor) for x, y in corner]
- first_return = ArcPath([entry, (corner_x, top), *corner[1:]])
+ approach = tuple(p - v * 55 for p, v in zip(base, self.runway, strict=True))
landing_turn = turn_points((0, -1), self.runway)
factor = (approach[0] - 442) / landing_turn[-1][0]
turn_start = (442, approach[1] - landing_turn[-1][1] * factor)
landing_turn = [
(turn_start[0] + x * factor, turn_start[1] + y * factor) for x, y in landing_turn
]
+ # Keep travelling north between the two return turns when the Classic
+ # touchdown point moves onto the deck, rather than reversing direction.
+ corner_y = max(137, turn_start[1] + 5)
+ corner = turn_points((1, 0), (0, -1))
+ factor = -(top - corner_y) / corner[-1][1]
+ corner_x = 442 - (top - corner_y)
+ corner = [(corner_x + x * factor, top + y * factor) for x, y in corner]
+ first_return = ArcPath([entry, (corner_x, top), *corner[1:]])
second_return = ArcPath([corner[-1], turn_start, *landing_turn[1:]])
self.returns = (first_return, second_return)
self.return_frames = tuple(math.ceil(path.length / self.step) for path in self.returns)
@@ -456,17 +463,30 @@ def __init__(
if index == 0
else hashlib.sha256(f"{self.seed}:jet:{index}".encode()).hexdigest()
)
- flight = Flight(
- calendar,
- fps,
- child_seed,
- targets=targets[index::jets],
- delay=delay,
- jet_id=index,
- carrier=carrier,
- )
+
+ def plan(start_delay, index=index, child_seed=child_seed):
+ return Flight(
+ calendar,
+ fps,
+ child_seed,
+ targets=targets[index::jets],
+ delay=start_delay,
+ jet_id=index,
+ carrier=carrier,
+ deck_offset=((jets - 1) / 2 - index) * RUNWAY_SPACING,
+ )
+
+ flight = plan(delay)
+ if self.flights:
+ # Reserve the runway in launch order. Waiting on deck keeps
+ # airborne routes continuous and lets each arrival stop behind
+ # the previous jet, in the same slot it occupied at the start.
+ earliest = self.flights[-1].landed + max(1, round(0.35 * fps))
+ if flight.touchdown < earliest:
+ delay += earliest - flight.touchdown
+ flight = plan(delay)
self.flights.append(flight)
- # The next jet enters the catapult after the previous aircraft clears it.
+ # The next queued jet starts its roll after the runway is clear.
delay = flight.launch_end + round(0.35 * fps) - round(0.4 * fps)
self.strikes = sorted(
(s for flight in self.flights for s in flight.strikes), key=lambda s: s.hit
@@ -491,10 +511,4 @@ def restored(self, cell, frame):
return frame >= self.reset_start + (cell.column / 53) * self.reset_frames
def visible_poses(self, frame):
- if frame >= self.landed:
- return (self.flights[0].pose(0),)
- return tuple(
- flight.pose(frame)
- for index, flight in enumerate(self.flights)
- if (index == 0 or frame >= self.flights[index - 1].launch_end) and frame < flight.landed
- )
+ return tuple(flight.pose(frame) for flight in self.flights)
diff --git a/tests/test_carriers.py b/tests/test_carriers.py
index 63f6651..6395cb3 100644
--- a/tests/test_carriers.py
+++ b/tests/test_carriers.py
@@ -39,10 +39,13 @@ def test_lincoln_is_the_default_and_preserves_the_approved_body(assert_carrier_b
def test_each_carrier_aligns_launch_and_landing_with_its_deck(carrier):
deck, bow = art.carrier_launch_points(carrier)
renderer = Renderer(demo_calendar(), jets=3, seed="carrier-deck", carrier=carrier)
+ sx, sy = art.project(*deck)
+ assert sum(f.deck[0] for f in renderer.flights) / 3 == pytest.approx(65 + sx)
+ assert sum(f.deck[1] for f in renderer.flights) / 3 == pytest.approx(12 + sy)
for flight in renderer.flights:
- sx, sy = art.project(*deck)
bx, by = art.project(*bow)
- assert flight.deck == (65 + sx, 12 + sy)
+ dx, dy = flight.deck[0] - (65 + sx), flight.deck[1] - (12 + sy)
+ assert dx * flight.runway[1] - dy * flight.runway[0] == pytest.approx(0)
assert flight.bow == (65 + bx, 12 + by)
assert (flight.pose(0).x, flight.pose(0).y) == flight.deck
assert (flight.pose(flight.landed).x, flight.pose(flight.landed).y) == flight.deck
diff --git a/tests/test_runway_queue.py b/tests/test_runway_queue.py
new file mode 100644
index 0000000..e22c38e
--- /dev/null
+++ b/tests/test_runway_queue.py
@@ -0,0 +1,86 @@
+import math
+
+import pytest
+from PIL import Image, ImageChops, ImageDraw
+
+from git_jet.art import project
+from git_jet.demo import demo_calendar
+from git_jet.flight import HEIGHT, WIDTH
+from git_jet.render import Renderer
+
+
+@pytest.mark.parametrize("carrier", ("lincoln", "classic"))
+@pytest.mark.parametrize("jets", (1, 2, 3))
+@pytest.mark.parametrize("fps", (5, 10, 20, 25))
+def test_jets_stay_visible_and_move_continuously_to_their_runway_slots(carrier, jets, fps):
+ renderer = Renderer(demo_calendar(), jets=jets, fps=fps, carrier=carrier, seed="runway-queue")
+ first = renderer.flight.visible_poses(0)
+ assert len(first) == jets
+ assert len({(p.x, p.y) for p in first}) == jets
+ previous = first
+ for frame in range(1, renderer.frame_count):
+ poses = renderer.flight.visible_poses(frame)
+ assert len(poses) == jets
+ for before, pose, flight in zip(previous, poses, renderer.flights, strict=True):
+ assert math.dist((before.x, before.y), (pose.x, pose.y)) * fps < 103
+ if frame < flight.launch_start or frame >= flight.touchdown:
+ assert pose.heading == pytest.approx(flight.takeoff_heading)
+ assert pose.bank == 0 and pose.thrust == 0
+ previous = poses
+ assert previous == first
+
+
+@pytest.mark.parametrize("carrier", ("lincoln", "classic"))
+@pytest.mark.parametrize("jets", (2, 3))
+def test_runway_is_clear_for_each_arrival_and_grounded_jets_do_not_overlap(carrier, jets):
+ renderer = Renderer(demo_calendar(), jets=jets, carrier=carrier, seed="runway-reservations")
+ for first, second in zip(renderer.flights, renderer.flights[1:], strict=False):
+ assert first.landed < second.touchdown
+ assert math.dist(first.deck, second.deck) >= 31
+ for frame in range(renderer.frame_count):
+ occupied = Image.new("1", (WIDTH, HEIGHT))
+ for flight in renderer.flights:
+ if flight.launch_end < frame < flight.touchdown:
+ continue
+ pose = flight.pose(frame)
+ sprite = renderer.jet_image(pose, frame)
+ mask = Image.new("1", (WIDTH, HEIGHT))
+ half = sprite.width // 2
+ mask.paste(
+ sprite.getchannel("A").convert("1"),
+ (round(pose.x) - half, round(pose.y) - half),
+ )
+ assert ImageChops.logical_and(occupied, mask).getbbox() is None, frame
+ occupied = ImageChops.logical_or(occupied, mask)
+
+
+@pytest.mark.parametrize("jets", (1, 2, 3))
+def test_classic_touchdown_and_parking_keep_the_whole_jet_on_deck(jets):
+ renderer = Renderer(demo_calendar(), jets=jets, carrier="classic", seed="classic-touchdown")
+ deck = Image.new("L", (WIDTH, HEIGHT))
+ ImageDraw.Draw(deck).polygon(
+ [
+ (65 + x, 12 + y)
+ for x, y in (
+ project(u, v, 25)
+ for u, v in (
+ (-18, 20),
+ (0, 0),
+ (248, 0),
+ (256, 12),
+ (256, 52),
+ (8, 52),
+ (-18, 36),
+ )
+ )
+ ],
+ fill=255,
+ )
+ for flight in renderer.flights:
+ for frame in [0, *range(flight.touchdown, flight.landed + 1)]:
+ pose = flight.pose(frame)
+ sprite = renderer.jet_image(pose, frame)
+ mask = Image.new("L", (WIDTH, HEIGHT))
+ half = sprite.width // 2
+ mask.paste(sprite.getchannel("A"), (round(pose.x) - half, round(pose.y) - half))
+ assert ImageChops.subtract(mask, deck).getbbox() is None