diff --git a/CHANGELOG.md b/CHANGELOG.md index b46ed27..8a79e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.0.0] - 2026-08-09 — The Sea Update + +### Added +- Four vessels join the roster via `-t`: `galleon` (three-masted pirate + ship with a flapping Jolly Roger, animated pennants, rigging, gunports, + and a rubber-duck figurehead), `steamer` (sternwheeler whose paddle + wheel turns on the same 4-frame machinery as the locomotive wheels, + with churn that streams aft), `sloop` (racing yacht for narrow + terminals), and `tug` (drawn in three-quarter perspective — it travels + diagonally on the angle its art implies, so it appears to grow as it + approaches the viewer) +- Animated sea for side-view vessels: a drifting swell pattern on the + surface, deterministic glints below, hull bob on a slow sine, bow + spray, and stern wake (three new particle kinds: spray, wake, bubble) +- `-d`/`--dolphin` — on special request, a dolphin dives ahead of the + ship along its drawn angle, cut cleanly at the waterline, with splash. + Requesting a dolphin with a train books sea passage instead +- At sea, `-a` finds an iceberg: crunch, shudder, bubbles, and a + dignified descent below the waterline. The iceberg wins +- At sea, `-F` is the Flying Dutchman: the ship climbs out of the water + trailing stardust while the sea stays below +- Per-vessel whistles for `-w`: the steamer HOOOONKs, the tug TOOOOTs, + the galleon goes YARRR!, the sloop rings its little bell +- Per-vessel liveries (white sails, red hulls, brass fittings), honored + in surprise mode too +- Surprise mode: about 1 typo in 3 is now nautical; dolphins accompany + 1 in 5 sea voyages +- CODE_OF_CONDUCT.md (Contributor Covenant 2.1), fixing the broken link in + CONTRIBUTING.md +- `--speed` help text now documents the 0.1–20 clamp + +### Changed +- `-n`/`--cars` is rail-only; coal cars do not float and the count is + ignored at sea (documented in `--help`) +- `-t`/`--type` now fully overrides `-l`: it no longer inherits the two + implicit coal cars (`-l` alone still couples them) + +### Fixed +- Trailing smoke now lingers and dissipates after the train exits, instead + of being cut off the moment the last car leaves the screen +- In accident mode on terminals narrower than the train, the crash now + happens at the left edge instead of off-screen +- `make demo` labeled its first run "Classic Train" but ran surprise mode; + it now runs `./sl -t classic` +- LICENSE copyright year updated to 2024-2026 + +### Notes +- Tug and dolphin artwork adapted from reference pieces contributed by + the project author + ## [3.1.0] - 2026-07-06 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b6c3d4c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,65 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in the +sl community a harassment-free experience for everyone, regardless of age, +body size, visible or invisible disability, ethnicity, sex characteristics, +gender identity and expression, level of experience, education, +socio-economic status, nationality, personal appearance, race, religion, or +sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. The train carries everyone. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our + mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political + attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our +standards of acceptable behavior and will take appropriate and fair +corrective action in response to any behavior that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces (issues, pull +requests, discussions), and also applies when an individual is officially +representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainer at +[contact@stevenmilanese.com](mailto:contact@stevenmilanese.com). +All complaints will be reviewed and investigated promptly and fairly, and +the privacy and security of the reporter will be respected. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1, +available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. diff --git a/LICENSE b/LICENSE index 5467ed2..514864a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Reverend Steven Milanese +Copyright (c) 2024-2026 Reverend Steven Milanese Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index f6ed1f1..d675046 100644 --- a/Makefile +++ b/Makefile @@ -32,12 +32,22 @@ test: # Run with different options demo: @echo "=== Classic Train ===" - ./sl + ./sl -t classic @echo "=== Flying Train ===" ./sl -F @echo "=== C51 Train ===" ./sl -c @echo "=== Accident ===" ./sl -a + @echo "=== Pirate Galleon ===" + ./sl -t galleon + @echo "=== Sternwheel Steamer ===" + ./sl -t steamer + @echo "=== Tug (perspective) ===" + ./sl -t tug + @echo "=== Dolphin Escort ===" + ./sl -t galleon -d + @echo "=== Iceberg ===" + ./sl -t steamer -a .PHONY: all install uninstall test demo \ No newline at end of file diff --git a/README.md b/README.md index fed73f4..9ed3fd6 100644 --- a/README.md +++ b/README.md @@ -43,17 +43,68 @@ Very rarely, it does not make it across. | Event | Odds | |-------|------| -| 📣 The train whistles | 1 in 4 | +| ⚓ The typo turns out to be nautical | about 1 in 3 | +| 📣 The train (or ship) whistles | 1 in 4 | +| 🐬 A dolphin escorts the voyage | 1 in 5 sea voyages | | ✈️ The train takes flight | 1 in 10 | -| 💥 The train does not arrive at the station | 1 in 20 | +| 💥 The journey does not reach its destination | 1 in 20 | Pass any flag and the dice are off — explicit options are fully deterministic, so your customizations always behave exactly as written. +## ⚓ New in 4.0: the rails end at a shoreline + +Four vessels join the roster. Type `sl -t galleon` and a three-masted +pirate ship crosses your terminal on an animated sea — flapping Jolly +Roger, turning nothing, fearing nothing, figurehead shaped like a rubber +duck. Her name is SQUEAKY. She has gunports. + +``` + |~~~~~~, + ` ` ` ` |x_x__/ + ` ` ` ` ` | ` ` ` + |> | ` ` ` + .___|___. .___|___. |> + ( | \ ( | \ | + ( | \ ( | \ .____|____. + (___________\ (___________\ ( ( | ) \ + ` | | ( ( | ) \ + .____|____. .____|____. ( ( | ) \ + ` ( ( | ) \ ( ( | ) \ (__)_________(__\ + ` /| ( ( | ) \ ( ( | ) \ | + / | ( ( | ) \ ( ( | ) \ /| _____ + ` / |(__)_________(__\ (__)_________(__\ / | __|~ ~ ~| + / | /|\ /|\ / || o o | + | ` ` `', + ' .___|___. .___|___. |>', + ' ( | \\ ( | \\ |', + ' ( | \\ ( | \\ .____|____.', + ' (___________\\ (___________\\ ( ( | ) \\', + ' ` | | ( ( | ) \\', + ' .____|____. .____|____. ( ( | ) \\', + ' ` ( ( | ) \\ ( ( | ) \\ (__)_________(__\\', + ' ` /| ( ( | ) \\ ( ( | ) \\ |', + ' / | ( ( | ) \\ ( ( | ) \\ /| _____', + ' ` / |(__)_________(__\\ (__)_________(__\\ / | __|~ ~ ~|', + ' / | /|\\ /|\\ / || o o |', + '', + ' | | | | |', + ' / | | | | \\ .---------.', + ' / | | | | \\ | o o o |', + ' _____________________________________', + ' | H H H H H H H H H |', + ' |> _____________________________________________ .=======.', + ' | | H H H H H H H H H H H | // | \\\\', + ' |____________________________________________________==||----O----||', + ' | | H H H H . T Y P O . H H H H | \\\\ | //', + " \\=====================================================/ '======='", + ' \\___________________________________________________/ ~o~O~o~O~', + ] + + # Racing sloop for narrow terminals + SLOOP = [ + ' |>', + ' | \\', + ' /| | \\', + ' / | | ) \\', + ' / | | ) \\', + ' / | | ) \\', + ' / | | ) \\', + ' /____| |_______\\ _/', + ' ____________|____________', + ' \\ o o o __/', + ' \\________________________/', + ] + + # Harbor tug, adapted from a reference piece; drawn in three-quarter + # perspective, so it travels diagonally toward the viewer + TUG = [ + ' $$$$$$$', + ' .ooooooo. $$!!!!!', + " .'.........'.$$!!!!!", + " .o' oooooo '$$!!!!! o$$oo.", + " ..o$ooo... $ '!!''!. $$!!!!!", + " $ .. '''oo$$$$$$$$$$$$$. ' 'oo. $$!!!!!", + " !....... '''..$$ $$ $$$ .. '$$!!''!", + " !!$$$!!!!!!!!oooo...... ''' $$ $$ :o", + " !!$$$!!!$$!$$!!!!!!!!!!oo..... ' '' o$$o .", + " !!!$$!!!!!!!!!!!!!!!!!!!!!!!!!!!!ooooo.. 'o oo.. $", + " '!!$$!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!oooooo.. '' ,$", + " '!!$!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!oooo..$$", + " !!$!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!$'", + " '$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$!!!!!!!!!!!!!!!!!!,", + ' .....$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$.....', + ] + + # The dolphin (special request). Dives down-left along its drawn angle. + DOLPHIN = [ + ' _', + ' _.-~~.)', + " _.--~~~~~---....__ .' . .,'", + " ,'. . . . . . . . . .~- ._ (", + ' ( .. .g. . . . . . . . . . .~-._', + '.~__.-~ ~`. . . . . . . . . . . -.', + '`----..._ ~-=~~-. . . . . . . . ~-.', + ' ~-._ `-._ ~=_~~--. . . . . .~.', + ' | .~-.._ ~--._-. ~-. . . . ~-.', + " \\ .( ~~--.._~' `. . . . .~-. ,", + " `._\\ ~~--.._ `. . . . . ~-. .- . ,'/", + ". _ . -~\\ _ .. _ ~~--.`_. . . . . ~-_ ,-','` .", + " ` ._ ~ ~--. . . . .~=.-'. /. `", + ' - . -~ -. _ . - ~ - _ - ~ ~--..__~ _,. / \\ -', + ' . __ .. ~- ~~_. ( `', + ' _ _ `- .. - . . - ~ ~ . \\ ~-` ` ` `.', + ' - . ` . \\ \\ `.', + ] + + # What the -a flag finds at sea + ICEBERG = [ + ' /\\', + ' / \\ /\\', + ' /\\/ \\ / \\', + ' / \\/\\/ \\', + ' / \\', + ' / \\', + ' (~~~~~~~~~~~~~~~~~~~~~~)', + ] + + VESSELS = { + "galleon": GALLEON, + "steamer": STEAMER, + "sloop": SLOOP, + "tug": TUG, + } + + SIDE_VIEW = ("galleon", "steamer", "sloop") # ride the drawn sea + PERSPECTIVE = ("tug",) # travel their drawn angle + + # Per-row 4-frame cycles: flapping colours, turning wheel, churning wake. + # Same contract as Train.WHEEL_SPEC: every frame matches pattern width. + ANIM_SPEC = { + 'galleon': [ + (0, '~~~~~~,', ['~~~~~~,', '~~~-~~.', '~~~~-~,', '~-~~~~.']), + (1, 'x_x__/', ['x_x__/', 'x_x_-.', 'x_x__)', "x_x,-'"]), + (3, '|>', ['|>', '|=', '|>', '|-']), + (4, '|>', ['|>', '|-', '|>', '|=']), + ], + 'steamer': [ + (0, '|>', ['|>', '|=', '|>', '|-']), + (6, '|>', ['|>', '|-', '|>', '|=']), + (6, '.=======.', ['.=======.', '.======-.', '.====-==.', '.==-====.']), + (7, '// | \\', ['// | \\', '// / \\', '// --- \\', '// \\ \\']), + (9, '\\ | //', ['\\ | //', '\\ \\ //', '\\ --- //', '\\ / //']), + (10, "'======='", ["'======='", "'-======'", "'==-===='", "'====-=='"]), + (11, '~o~O~o~O~', ['~o~O~o~O~', '~~o~O~o~O', 'O~~o~O~o~', '~O~~o~O~o']), + ], + 'sloop': [ + (0, '|>', ['|>', '|=', '|>', '|-']), + ], + 'tug': [ + ], + } + + # Smoke emitters as (dx, dy) from the art's top-left corner + STACKS = { + "steamer": [(13, -1), (21, -1)], + "tug": [(34, -1), (46, 2)], + } + + # Row of the art that sits on the water surface + WATERLINE = { + "galleon": 20, + "steamer": 11, + "sloop": 10, + "tug": 14, + } + + WHISTLE = { + "galleon": "YARRR!", + "steamer": "HOOOONK!", + "sloop": "ding! ding!", + "tug": "TOOOOT!", + } + + # Per-vessel liveries (character-class colouring, like CHAR_COLORS) + COLORS = { + "galleon": {'(': ANSI.WHITE, ')': ANSI.WHITE, '\\': ANSI.WHITE, + '.': ANSI.WHITE, '`': ANSI.WHITE, 'x': ANSI.WHITE, + '~': ANSI.WHITE, '|': ANSI.YELLOW, '_': ANSI.YELLOW, + '/': ANSI.YELLOW, '<': ANSI.YELLOW, 'o': ANSI.YELLOW, + '>': ANSI.RED, '=': ANSI.RED, '-': ANSI.RED, + '[': ANSI.YELLOW, ']': ANSI.YELLOW}, + "steamer": {'|': ANSI.WHITE, '_': ANSI.WHITE, 'H': ANSI.WHITE, + '.': ANSI.WHITE, "'": ANSI.WHITE, '-': ANSI.WHITE, + '=': ANSI.RED, 'O': ANSI.YELLOW, 'o': ANSI.YELLOW, + '>': ANSI.RED, '/': ANSI.WHITE, '\\': ANSI.WHITE, + '~': ANSI.CYAN}, + "sloop": {'|': ANSI.WHITE, '\\': ANSI.WHITE, '/': ANSI.WHITE, + ')': ANSI.WHITE, '_': ANSI.YELLOW, '.': ANSI.WHITE, + 'o': ANSI.YELLOW, '>': ANSI.RED, '~': ANSI.CYAN}, + "tug": {'$': ANSI.RED, '!': ANSI.YELLOW, 'o': ANSI.WHITE, + '.': ANSI.WHITE, "'": ANSI.WHITE, ',': ANSI.WHITE, + ':': ANSI.WHITE}, + } + + @classmethod + def frames(cls, style): + """Build the 4-frame animation cycle for a vessel style.""" + base = cls.VESSELS.get(style, cls.GALLEON) + frames = [] + for k in range(4): + art = list(base) + for row, pattern, cycle in cls.ANIM_SPEC.get(style, []): + art[row] = art[row].replace(pattern, cycle[k]) + frames.append(art) + return frames + + class Screen: """Double-buffered frame composer: draw everything into an off-screen cell buffer, then emit the whole frame as one write. No per-frame @@ -286,6 +501,9 @@ class Particles: "smoke": {"chars": "@@Oo*..", "gravity": 0.0, "drag": 0.98}, "spark": {"chars": "@**+x..", "gravity": 0.12, "drag": 1.0}, "stardust": {"chars": "**++...", "gravity": 0.0, "drag": 0.99}, + "spray": {"chars": "oO*'..", "gravity": 0.18, "drag": 0.96}, + "wake": {"chars": "oO~-..", "gravity": 0.0, "drag": 0.97}, + "bubble": {"chars": ".oOo..", "gravity": -0.06, "drag": 0.98}, } def __init__(self, smoke_colors: List[str]): @@ -321,6 +539,36 @@ class Particles: "age": 0, "life": random.randint(8, 14), }) + def emit_spray(self, x: float, y: float): + """Foam kicked up where a hull (or a dolphin) meets the water.""" + self.items.append({ + "kind": "spray", + "x": x + random.uniform(-1, 1), "y": y, + "vx": -random.uniform(0.1, 0.6), + "vy": -random.uniform(0.2, 0.7), + "age": 0, "life": random.randint(8, 14), + }) + + def emit_wake(self, x: float, y: float): + """Churned water trailing off the stern.""" + self.items.append({ + "kind": "wake", + "x": x, "y": y + random.uniform(0, 0.6), + "vx": random.uniform(0.3, 0.8), + "vy": random.uniform(-0.05, 0.1), + "age": 0, "life": random.randint(10, 16), + }) + + def emit_bubble(self, x: float, y: float): + """Air escaping a ship that is no longer, strictly, a ship.""" + self.items.append({ + "kind": "bubble", + "x": x + random.uniform(-1, 1), "y": y, + "vx": random.uniform(-0.2, 0.2), + "vy": -random.uniform(0.05, 0.25), + "age": 0, "life": random.randint(10, 20), + }) + def step(self): alive = [] for p in self.items: @@ -345,7 +593,11 @@ class Particles: color = self.smoke_colors[idx] elif p["kind"] == "spark": color = ANSI.WHITE if t < 0.3 else (ANSI.YELLOW if t < 0.6 else ANSI.RED) - else: + elif p["kind"] == "spray": + color = ANSI.WHITE if t < 0.4 else ANSI.CYAN + elif p["kind"] == "bubble": + color = ANSI.CYAN if t < 0.6 else ANSI.WHITE + else: # stardust and wake share a sea-foam fade color = ANSI.CYAN if t < 0.5 else ANSI.WHITE screen.put(int(round(p["x"])), int(round(p["y"])), ch, color=color) @@ -357,7 +609,8 @@ class SLAnimation: fly: bool = False, accident: bool = False, cars: int = 0, whistle: bool = False, use_color: bool = True, - palette: Optional[Dict[str, str]] = None): + palette: Optional[Dict[str, str]] = None, + dolphin: bool = False): self.train_type = train_type self.speed = max(0.1, min(speed, 20.0)) self.fly = fly @@ -368,15 +621,38 @@ class SLAnimation: self.running = True self.resized = False - base = Train.TRAINS.get(train_type, Train.CLASSIC) - self.frames = [Train.couple(f, cars) for f in Train.frames(train_type)] + self.is_vessel = train_type in Vessel.VESSELS + self.dolphin = dolphin and self.is_vessel + if self.is_vessel: + # Side-view craft sail the animated sea; perspective craft + # travel the diagonal their artwork implies. + self.scene = 'persp' if train_type in Vessel.PERSPECTIVE else 'sea' + self.frames = Vessel.frames(train_type) + self.stacks = list(Vessel.STACKS.get(train_type, [])) + self.waterline = Vessel.WATERLINE[train_type] + self.toot_text = Vessel.WHISTLE.get(train_type, 'TOOT! TOOT!') + self.funnel_dx = self.stacks[0][0] if self.stacks else 10 + else: + self.scene = 'rail' + base = Train.TRAINS.get(train_type, Train.CLASSIC) + self.frames = [Train.couple(f, cars) + for f in Train.frames(train_type)] + self.waterline = 0 + self.toot_text = 'TOOT! TOOT!' self.total_w = max(len(r) for r in self.frames[0]) self.total_h = len(self.frames[0]) - # If a coal car is taller than the loco, the loco is padded down - self.funnel_dy = self.total_h - len(base) - self.funnel_dx = Train.FUNNEL_X.get(train_type, 7) + if self.scene == 'rail': + # If a coal car is taller than the loco, the loco is padded down + self.funnel_dy = self.total_h - len(base) + self.funnel_dx = Train.FUNNEL_X.get(train_type, 7) + self.stacks = [(self.funnel_dx, self.funnel_dy - 1)] + else: + self.funnel_dy = 0 self.smoke_colors, self.rail_color = self._palette() + self.dolphin_color = (ANSI.gray(251) + if self.smoke_colors[0] != ANSI.WHITE + else ANSI.CYAN) self.update_terminal_size() signal.signal(signal.SIGINT, self._handle_interrupt) @@ -419,9 +695,11 @@ class SLAnimation: return p * p * (3 - 2 * p) def print_static(self): - """stdout is not a terminal: print one honest train, no escapes.""" + """stdout is not a terminal: print one honest craft, no escapes.""" for line in self.frames[0]: print(line.rstrip()) + if self.is_vessel: + print('~' * self.total_w) def run(self): """Run the animation.""" @@ -432,7 +710,10 @@ class SLAnimation: sys.stdout.write(ANSI.ALT_SCREEN_ON + ANSI.HIDE_CURSOR) sys.stdout.flush() try: - self._animate() + if self.scene == 'sea': + self._animate_sea() + else: + self._animate() finally: # The alternate screen restores whatever was there before sys.stdout.write(ANSI.SHOW_CURSOR + ANSI.ALT_SCREEN_OFF) @@ -461,6 +742,13 @@ class SLAnimation: y = int(2 + (floor_y - 2) * (1 - self._smooth(progress)) + 1.5 * math.sin(progress * 7)) y = max(1, min(self.height - self.total_h - 1, y)) + elif self.scene == 'persp': + # Perspective craft descend as they cross: the travel path + # matches the angle the art was drawn at, so the craft + # reads as approaching the viewer. + drop = max(2, self.height // 3) + y = max(0, (self.height - self.total_h) // 2 - drop // 2) \ + + int(drop * self._smooth(progress)) else: y = max(1, (self.height - self.total_h) // 2) @@ -471,7 +759,8 @@ class SLAnimation: if frame_i % 2 == 0: particles.emit_stardust(x + self.total_w, y + self.total_h - 2) if frame_i % 2 == 0: - particles.emit_smoke(x + self.funnel_dx, y + self.funnel_dy - 1) + for sx, sy in self.stacks: + particles.emit_smoke(x + sx, y + sy) particles.step() # Whistle as the funnel passes the marks @@ -481,7 +770,7 @@ class SLAnimation: # Compose the frame: rails, smoke, train, overlays screen.clear() - if not self.fly: + if not self.fly and self.scene == 'rail': screen.put(0, y + self.total_h, '-' * self.width, color=self.rail_color) particles.draw(screen) @@ -489,13 +778,15 @@ class SLAnimation: screen.put(x, y + i, line, charmap=self.palette, opaque=True) if frame_i < toot_until: screen.put(x + self.funnel_dx + 3, y + self.funnel_dy - 2, - 'TOOT! TOOT!', color=ANSI.WHITE) + self.toot_text, color=ANSI.WHITE) sys.stdout.write(screen.frame()) sys.stdout.flush() - # The -a flag: the train makes it halfway, and no further - if self.accident and x <= (self.width - self.total_w) // 2: + # The -a flag: the train makes it halfway, and no further. + # On terminals narrower than the train, crash at the left edge + # so the wreck stays visible. + if self.accident and x <= max(0, (self.width - self.total_w) // 2): self._crash(screen, particles, x, y, art) return @@ -508,8 +799,176 @@ class SLAnimation: next_tick = time.monotonic() frame_i += 1 - if x + self.total_w < 0: - break + def _draw_sea(self, screen: Screen, water_y: int, frame_i: int): + """Animated sea: a drifting swell pattern on the surface and + deterministic glints in the depths (no randomness, so the water + shimmers instead of boiling).""" + pat = '~~~~ ~~~ ~~~~~ ~~ ~~~ ~~~~ ' + off = (frame_i // 2) % len(pat) + row = (pat * (self.width // len(pat) + 2))[off:off + self.width] + screen.put(0, water_y, row, color=ANSI.CYAN) + deep = self.rail_color or ANSI.CYAN + for gy in range(water_y + 1, self.height): + step = 11 + (gy * 7) % 9 + phase = (frame_i // 3 + gy * 5) % step + for gx in range(phase, self.width, step): + screen.put(gx, gy, '~' if (gx + gy) % 3 else '.', color=deep) + + def _animate_sea(self): + """A sea voyage: animated water, a bobbing hull, spray and wake, + optionally a dolphin, and -- with -a -- an iceberg.""" + screen = Screen(self.width, self.height, self.use_color) + particles = Particles(self.smoke_colors) + frame_dt = 0.05 / self.speed + total_frames = self.width + self.total_w + 24 + toot_until = -1 + toot_marks = {int(self.width * 0.66), int(self.width * 0.33)} + next_tick = time.monotonic() + frame_i = 0 + + # The sea rises to meet tall ships; it pans down slightly across + # the crossing so the whole scene leans toward the viewer. + sea0 = max(1, min(max(int(self.height * 0.60), self.waterline), + self.height - 3)) + pan = max(0, min(2, (self.height - 3) - sea0)) + ice_x = max(2, self.width // 6) + dolphin_at = self.width // 4 if self.dolphin else -1 + dol_h = len(Vessel.DOLPHIN) + + while self.running and frame_i < total_frames: + if self.resized: + self.resized = False + self.update_terminal_size() + screen = Screen(self.width, self.height, self.use_color) + sea0 = max(1, min(max(int(self.height * 0.60), + self.waterline), self.height - 3)) + pan = max(0, min(2, (self.height - 3) - sea0)) + + x = self.width - frame_i + progress = frame_i / total_frames + water_y = sea0 + int(pan * self._smooth(progress)) + if self.fly: + # The Flying Dutchman: the sea stays low, the ship does not + water_y = self.height - 3 + floor_y = max(1, water_y - self.total_h - 1) + y = int(2 + (floor_y - 2) * (1 - self._smooth(progress)) + + 1.5 * math.sin(progress * 7)) + y = max(1, min(floor_y, y)) + else: + bob = int(round(math.sin(frame_i * 0.15) * 0.9)) + y = water_y - self.waterline + bob + + art = self.frames[(frame_i // 2) % len(self.frames)] + + # Spray at the bow, wake off the stern, smoke from the stacks + if self.fly: + if frame_i % 2 == 0: + particles.emit_stardust(x + self.total_w, + y + self.total_h - 2) + else: + if frame_i % 2 == 0: + particles.emit_spray(x + 4, water_y) + if frame_i % 3 == 0: + particles.emit_wake(x + self.total_w - 3, water_y) + if frame_i % 2 == 0: + for sx, sy in self.stacks: + particles.emit_smoke(x + sx, y + sy) + particles.step() + + if self.whistle and (x + 10) in toot_marks: + sys.stdout.write(ANSI.BELL) + toot_until = frame_i + 8 + + screen.clear() + self._draw_sea(screen, water_y, frame_i) + + # The dolphin dives ahead of the ship, on its drawn angle + if dolphin_at >= 0 and frame_i >= dolphin_at: + t = frame_i - dolphin_at + dy = water_y - dol_h + 2 + t // 2 + dx = int(self.width * 0.45) - 40 - t + if dy >= water_y: + dolphin_at = -1 # fully sounded; gone + else: + for i, line in enumerate(Vessel.DOLPHIN): + if dy + i < water_y: + screen.put(dx, dy + i, line, + color=self.dolphin_color) + i0 = water_y - dy # art row crossing the surface + particles.emit_spray(dx + max(2, 2 * (dol_h - i0)), + water_y) + + particles.draw(screen) + + if self.accident and not self.fly: + by = water_y - len(Vessel.ICEBERG) + 1 + for i, line in enumerate(Vessel.ICEBERG): + screen.put(ice_x, by + i, line, color=ANSI.WHITE) + + for i, line in enumerate(art): + screen.put(x, y + i, line, charmap=self.palette, opaque=True) + if frame_i < toot_until: + screen.put(x + 12, max(0, y - 2), self.toot_text, + color=ANSI.WHITE) + + sys.stdout.write(screen.frame()) + sys.stdout.flush() + + # The -a flag, at sea: the iceberg wins. It always wins. + if self.accident and not self.fly and x <= ice_x + 16: + self._sink(screen, particles, x, y, art, water_y, ice_x) + return + + next_tick += frame_dt + delay = next_tick - time.monotonic() + if delay > 0: + time.sleep(delay) + else: + next_tick = time.monotonic() + frame_i += 1 + + def _sink(self, screen: Screen, particles: Particles, + x: int, y: int, art: List[str], water_y: int, ice_x: int): + """Iceberg finale: a crunch, a shudder, and a dignified descent.""" + depth = 0 + frames = 30 + (self.total_h + 2) * 3 + for f in range(frames): + if not self.running: + return + if f < 12: + for _ in range(4): + particles.emit_spray(x + random.randint(0, 8), + water_y - random.randint(0, 2)) + if f >= 14: + depth = (f - 14) // 3 + if f % 2 == 0: + particles.emit_bubble( + x + random.randint(4, max(5, self.total_w - 4)), + water_y + 1) + particles.step() + + shake = random.randint(-1, 1) if f < 12 else 0 + + screen.clear() + self._draw_sea(screen, water_y, f) + by = water_y - len(Vessel.ICEBERG) + 1 + for i, line in enumerate(Vessel.ICEBERG): + screen.put(ice_x, by + i, line, color=ANSI.WHITE) + particles.draw(screen) + sy = y + depth + for i, line in enumerate(art): + if sy + i <= water_y: # what's under stays under + screen.put(x + shake, sy + i, line, + charmap=self.palette, opaque=True) + if f < 34 and (f // 3) % 2 == 0: + msg = 'C R U N C H !' if f < 14 else 'B L U B B L U B' + color = ANSI.RED if f < 14 else ANSI.CYAN + screen.put(max(2, x + 6), max(1, water_y - self.total_h - 1), + msg, color=color) + + sys.stdout.write(screen.frame()) + sys.stdout.flush() + time.sleep(0.06 / self.speed) def _crash(self, screen: Screen, particles: Particles, x: int, y: int, art: List[str]): @@ -532,8 +991,9 @@ class SLAnimation: dy = random.randint(-1, 0) if shake else 0 screen.clear() - screen.put(0, y + self.total_h, '-' * self.width, - color=self.rail_color) + if self.scene == 'rail': + screen.put(0, y + self.total_h, '-' * self.width, + color=self.rail_color) particles.draw(screen) for i, line in enumerate(art): screen.put(x + dx, y + i + dy, line, @@ -563,14 +1023,19 @@ def main(): help='use a longer train (D51 pulling coal cars)') parser.add_argument('-c', '--C51', action='store_true', help='use C51 train type') - parser.add_argument('-t', '--type', choices=sorted(Train.TRAINS), - help='locomotive type (overrides -l/-c)') + parser.add_argument('-t', '--type', + choices=sorted(Train.TRAINS) + sorted(Vessel.VESSELS), + help='locomotive or vessel type (overrides -l/-c)') parser.add_argument('-n', '--cars', type=int, default=0, metavar='N', - help='number of coal cars to pull (default: 0)') + help='number of coal cars to pull (default: 0; ' + 'rail only -- coal cars do not float)') + parser.add_argument('-d', '--dolphin', action='store_true', + help='a dolphin joins the voyage (implies a vessel)') parser.add_argument('-w', '--whistle', action='store_true', help='sound the whistle as the train passes') parser.add_argument('-s', '--speed', type=float, default=1.0, - help='animation speed multiplier (default: 1.0)') + help='animation speed multiplier, ' + 'clamped to 0.1-20 (default: 1.0)') parser.add_argument('--no-color', action='store_true', help='disable colors (NO_COLOR is also honored)') parser.add_argument('-v', '--version', action='version', @@ -578,19 +1043,25 @@ def main(): args = parser.parse_args() - # Determine train type + # Determine what rolls out (or sets sail) if args.type: - train_type = args.type + craft = args.type elif args.C51: - train_type = "c51" + craft = "c51" elif args.long: - train_type = "d51" + craft = "d51" else: - train_type = "classic" + craft = "classic" + + # A dolphin will not follow a train. Requesting one books sea passage. + if args.dolphin and craft not in Vessel.VESSELS: + craft = random.choice(Vessel.SIDE_VIEW) cars = max(0, min(args.cars, 8)) - if args.long and args.cars == 0: - cars = 2 # --long should actually be long + if args.long and not args.type and args.cars == 0: + cars = 2 # --long should actually be long (unless -t overrides it) + if craft in Vessel.VESSELS: + cars = 0 # coal cars do not float use_color = (not args.no_color and 'NO_COLOR' not in os.environ @@ -602,19 +1073,29 @@ def main(): palette = None speed = args.speed fly, accident, whistle = args.fly, args.accident, args.whistle + dolphin = args.dolphin if len(sys.argv) == 1: - train_type = random.choice(list(Train.TRAINS)) - cars = random.choice([0, 0, 0, 1, 2, 2, 3, 4, 8]) - palette = random.choice(COLOR_THEMES + [random_theme()]) + if random.random() < 0.35: # some typos are nautical + craft = random.choice(sorted(Vessel.VESSELS)) + dolphin = (craft in Vessel.SIDE_VIEW + and random.random() < 0.20) + else: + craft = random.choice(list(Train.TRAINS)) + cars = random.choice([0, 0, 0, 1, 2, 2, 3, 4, 8]) + palette = random.choice(COLOR_THEMES + [random_theme()]) speed = random.uniform(0.8, 1.5) whistle = random.random() < 0.25 roll = random.random() accident = roll < 0.05 # rare: the typo ends in tragedy fly = 0.05 <= roll < 0.15 # rare: the typo takes flight + # Vessels sail under their own colors, surprise or not + if craft in Vessel.VESSELS: + palette = Vessel.COLORS.get(craft, CHAR_COLORS) + # Run animation animation = SLAnimation( - train_type=train_type, + train_type=craft, speed=speed, fly=fly, accident=accident, @@ -622,6 +1103,7 @@ def main(): whistle=whistle, use_color=use_color, palette=palette, + dolphin=dolphin, ) animation.run()