Release 3.0.0: double-buffered renderer, animated wheels, coal cars, whistle

Rewrite the animation engine around a flicker-free double-buffered
renderer on the alternate screen. Add turning wheels, particle smoke,
coal cars (-n), train type selection (-t), whistle (-w), rebuilt fly
and accident modes, --no-color/NO_COLOR support, and a static train
when stdout is not a terminal. All v2 flags remain compatible.
This commit is contained in:
developtheweb 2026-07-06 13:03:51 -04:00
parent 53946c1897
commit d0b698eb40
3 changed files with 508 additions and 211 deletions

View file

@ -7,11 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [3.0.0] - 2026-07-06
### Added
- Flicker-free double-buffered renderer on the alternate screen — the frame
is composed off-screen and emitted as a single write, and your previous
terminal contents are restored when the train has passed
- Animated wheels with a 4-frame rotation cycle on every train type
- Particle-based smoke that drifts behind the train and dissipates, with
grayscale shading on 256-color terminals
- Coal cars (`-n`/`--cars`, up to 8), adapted from Toyoda Masashi's original sl
- Train type selection flag (`-t`/`--type`: classic, small, d51, c51)
- Whistle (`-w`/`--whistle`) — the train toots as it passes
- Rebuilt accident mode: screen shake, spark shower, and smoldering wreck
- Rebuilt flying mode: smoothstep climb with stardust trail
- Static train output when stdout is not a terminal, so `sl | cat` prints
a train instead of escape codes
- Color control: `--no-color` flag, `NO_COLOR` environment variable honored
- Monotonic frame pacing (animation speed no longer drifts with render time)
- Speed clamping (0.120.0) so `-s 0` can no longer hang the animation
- Professional project documentation (CONTRIBUTING.md, etc.)
- Enhanced README with badges and comprehensive information
- Links to [StevenMilanese.com](https://stevenmilanese.com) throughout documentation
### Changed
- `-l`/`--long` now couples two coal cars behind the D51, making the long
train actually long
- Terminal resize is applied at the next frame instead of mid-draw
- SIGWINCH handler is only registered on platforms that have it (Windows
compatibility)
### Fixed
- Full-screen clear per frame caused visible flicker; replaced by the
double-buffered renderer
- Character-by-character color replacement could corrupt output when the
train was clipped at the screen edge
- Escape codes were written even when stdout was a pipe or file
- Flying mode drifted downward instead of flying
- Degenerate terminal sizes (0×0 pseudo-terminals) now fall back to 80×24
## [2.0.0] - 2024-06-05
### Added

View file

@ -31,12 +31,17 @@ __/ =| o |=-~~\ /~~\ /~~\ /~~\ ____Y___________|__
## ✨ Features
- 🚂 **Multiple train types** - Classic, D51, and C51 locomotives
- ✈️ **Flying mode** - Make the train fly across the sky (`-F`)
- 💥 **Accident mode** - Watch a dramatic crash (`-a`)
- 🎨 **Colorful ASCII art** - Beautiful colored trains with dynamic smoke
- 🚂 **Multiple train types** - Classic, Small, D51, and C51 locomotives (`-t`)
- 🛞 **Animated wheels** - Wheels that actually turn as the train rolls
- 💨 **Particle smoke** - Smoke that drifts behind the train and dissipates
- 🚃 **Coal cars** - Couple up to 8 tenders behind the locomotive (`-n`)
- ✈️ **Flying mode** - Make the train fly across the sky with a stardust trail (`-F`)
- 💥 **Accident mode** - Screen shake, sparks, and a proper BOOM (`-a`)
- 📣 **Whistle** - The train toots as it passes (`-w`)
- 🖥️ **Flicker-free rendering** - Double-buffered frames on the alternate screen; your terminal contents are restored when the train has passed
- 🎨 **Colorful ASCII art** - Colored trains with grayscale smoke on 256-color terminals; honors `NO_COLOR` and `--no-color`
- ⚡ **Adjustable speed** - Control animation speed
- 📐 **Terminal-aware** - Handles terminal resizing gracefully
- 📐 **Terminal-aware** - Handles terminal resizing gracefully; `sl | cat` prints a static train instead of escape codes
- 🛡️ **Clean exit** - Proper cleanup and Ctrl+C handling
- 🪶 **Lightweight** - No external dependencies, pure Python
@ -116,7 +121,10 @@ $ sl # Classic train
$ sl -F # Flying train
$ sl -a # Train accident
$ sl -c # C51 train type
$ sl -l # Long train (D51)
$ sl -l # Long train (D51 pulling coal cars)
$ sl -n 4 # Pull 4 coal cars
$ sl -t small # Small train for narrow terminals
$ sl -w # Sound the whistle
$ sl -s 2.0 # Double speed
$ sl --help # Show help
```
@ -127,9 +135,13 @@ $ sl --help # Show help
|--------|-----------|-------------|
| `-a` | `--accident` | An accident occurs partway through |
| `-F` | `--fly` | Make the train fly through the sky |
| `-l` | `--long` | Use a longer train (D51) |
| `-l` | `--long` | Use a longer train (D51 pulling coal cars) |
| `-c` | `--C51` | Use the C51 train type |
| `-s` | `--speed` | Animation speed multiplier (default: 1.0) |
| `-t` | `--type` | Locomotive type: `classic`, `small`, `d51`, `c51` (overrides `-l`/`-c`) |
| `-n` | `--cars` | Number of coal cars to pull, up to 8 (default: 0) |
| `-w` | `--whistle` | Sound the whistle as the train passes |
| `-s` | `--speed` | Animation speed multiplier, 0.120 (default: 1.0) |
| | `--no-color` | Disable colors (`NO_COLOR` is also honored) |
| `-v` | `--version` | Show version information |
| `-h` | `--help` | Show help message |

657
sl
View file

@ -4,18 +4,30 @@ sl - Steam Locomotive
A joke command that displays an animated train when you type 'sl' instead of 'ls'
Author: Reverend Steven Milanese
License: MIT
Design notes (v3):
The entire point of sl is maximal output for minimal input -- two
mistyped letters buy you a locomotive. v3 keeps the original contract
(one file, stdlib only, same flags, Ctrl+C still works) and upgrades
the show: a flicker-free double-buffered renderer on the alternate
screen, wheels that actually turn, smoke that drifts and dissipates,
coal cars, a whistle, and a crash that earns the -a flag. When stdout
is not a terminal, a static train is printed instead of escape codes,
so `sl | cat` stays a train and not a mess.
"""
import argparse
import math
import os
import sys
import time
import random
import signal
import argparse
from typing import List, Tuple
import sys
import time
from typing import Dict, List, Optional
# Version
__version__ = "2.0.0"
__version__ = "3.0.0"
# ANSI escape codes for colors and cursor control
class ANSI:
@ -28,22 +40,40 @@ class ANSI:
CYAN = '\033[96m'
WHITE = '\033[97m'
RESET = '\033[0m'
# Cursor control
# Cursor and screen control
HIDE_CURSOR = '\033[?25l'
SHOW_CURSOR = '\033[?25h'
CLEAR_SCREEN = '\033[2J\033[H'
CLEAR_LINE = '\033[2K'
ALT_SCREEN_ON = '\033[?1049h'
ALT_SCREEN_OFF = '\033[?1049l'
BELL = '\a'
@staticmethod
def gray(level: int) -> str:
"""256-color grayscale (232 = near black .. 255 = near white)."""
return f'\033[38;5;{level}m'
@staticmethod
def move_cursor(x: int, y: int) -> str:
"""Move cursor to position."""
"""Move cursor to position (1-based)."""
return f'\033[{y};{x}H'
# Character-based coloring of the train art (original v2 palette)
CHAR_COLORS = {
'D': ANSI.RED,
'_': ANSI.YELLOW,
'|': ANSI.BLUE,
'=': ANSI.GREEN,
'O': ANSI.WHITE,
'o': ANSI.WHITE,
'~': ANSI.CYAN,
}
class Train:
"""Represents the ASCII art train with different styles."""
"""The ASCII art trains, their animated wheel frames, and rolling stock."""
# Classic steam locomotive
CLASSIC = [
" ==== ________ ___________",
@ -57,8 +87,8 @@ class Train:
" |/-=|___|= || || || |_____/~\\___/ ",
" \\_/ \\O=====O=====O=====O_/ \\_/ "
]
# Small locomotive for faster animation
# Small locomotive for narrow terminals
SMALL = [
" ++ +------ ",
" || |+-+ | ",
@ -67,7 +97,7 @@ class Train:
" _|--O========O~\\-+ ",
"//// \\_/ \\_/ "
]
# D51 locomotive (Japanese style)
D51 = [
" ==== ________ ___________ ",
@ -81,7 +111,7 @@ class Train:
" |/-=|___|= || || || |_____/~\\___/ ",
" \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ "
]
# C51 locomotive
C51 = [
" ___ ",
@ -96,248 +126,469 @@ class Train:
"/~\\____|___|/~\\_| O=======O=======O |__|\\ / ",
"\\_/ \\_/ \\____/ \\____/ \\____/ \\_____/ "
]
@staticmethod
def get_train(style: str = "classic") -> List[str]:
"""Get train ASCII art by style."""
trains = {
"classic": Train.CLASSIC,
"small": Train.SMALL,
"D51": Train.D51,
"C51": Train.C51
}
return trains.get(style, Train.CLASSIC)
# Coal tender, adapted from Toyoda Masashi's original sl
COAL_CAR = [
" ",
" _________________ ",
" _| \\_____A ",
" =| | ",
" -| | ",
"__|________________________|_ ",
"|__________________________|_ ",
" |_D__D__D_| |_D__D__D_| ",
" \\_/ \\_/ \\_/ \\_/ ",
]
class SmokeGenerator:
"""Generates dynamic smoke patterns."""
SMOKE_CHARS = ['@', '*', 'o', 'O', '.', '\'', '"']
@staticmethod
def generate(length: int, density: float = 0.4) -> str:
"""Generate a smoke pattern."""
smoke = []
for _ in range(length):
if random.random() < density:
smoke.append(random.choice(SmokeGenerator.SMOKE_CHARS))
else:
smoke.append(' ')
return ''.join(smoke)
@staticmethod
def get_smoke_lines(width: int, height: int = 4) -> List[str]:
"""Generate multiple lines of smoke."""
lines = []
TRAINS = {
"classic": CLASSIC,
"small": SMALL,
"d51": D51,
"c51": C51,
}
# Wheel animation: (row index, pattern, 4-frame cycle). Each cycle entry
# must be the same width as the pattern it replaces.
WHEEL_SPEC = {
"classic": [
(8, "||", ["||", "//", "--", "\\\\"]),
(9, "=====", ["=====", "-====", "==-==", "===-="]),
],
"d51": [
(7, "=====", ["=====", "-====", "==-==", "===-="]),
(8, "||", ["||", "//", "--", "\\\\"]),
],
"c51": [
(9, "=======", ["=======", "-======", "===-===", "=====-="]),
],
"small": [
(4, "========", ["========", "-=======", "===-====", "=====-=="]),
],
}
# Column of the smokestack, relative to the left edge of the art
FUNNEL_X = {"classic": 7, "d51": 7, "c51": 9, "small": 5}
@classmethod
def frames(cls, style: str) -> List[List[str]]:
"""Build the wheel-animation frames for a train style."""
base = cls.TRAINS.get(style, cls.CLASSIC)
frames = []
for k in range(4):
art = list(base)
for row, pattern, cycle in cls.WHEEL_SPEC.get(style, []):
art[row] = art[row].replace(pattern, cycle[k])
frames.append(art)
return frames
@classmethod
def couple(cls, train_art: List[str], cars: int) -> List[str]:
"""Attach coal cars behind the locomotive, bottom-aligned."""
parts = [train_art] + [cls.COAL_CAR] * max(0, cars)
height = max(len(p) for p in parts)
widths = [max(len(r) for r in p) for p in parts]
rows = []
for i in range(height):
density = 0.4 - (i * 0.1) # Decrease density as smoke rises
lines.append(SmokeGenerator.generate(width, density))
return lines
row = ""
for part, width in zip(parts, widths):
pad = height - len(part)
src = part[i - pad] if i >= pad else ""
row += src.ljust(width)
rows.append(row)
return rows
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
clear-screen means no flicker."""
def __init__(self, width: int, height: int, use_color: bool):
self.w = width
self.h = height
self.use_color = use_color
self.chars: List[List[str]] = []
self.colors: List[List[Optional[str]]] = []
self.clear()
def clear(self):
self.chars = [[' '] * self.w for _ in range(self.h)]
self.colors = [[None] * self.w for _ in range(self.h)]
def put(self, x: int, y: int, text: str,
color: Optional[str] = None,
charmap: Optional[Dict[str, str]] = None,
opaque: bool = False):
"""Draw text at (x, y), clipping to the screen. Spaces are
transparent unless opaque, in which case interior spaces (between
the first and last visible character) overwrite what's below."""
if not (0 <= y < self.h) or not text:
return
start = end = 0
if opaque:
body = text.rstrip()
start = len(body) - len(body.lstrip())
end = len(body)
row, crow = self.chars[y], self.colors[y]
for i, ch in enumerate(text):
if ch == ' ' and not (opaque and start <= i < end):
continue
cx = x + i
if 0 <= cx < self.w:
row[cx] = ch
if charmap and ch in charmap:
crow[cx] = charmap[ch]
else:
crow[cx] = color
def frame(self) -> str:
"""Serialize the buffer to a single escape-code string."""
parts = [ANSI.RESET] if self.use_color else []
current = None
for y in range(self.h):
parts.append(ANSI.move_cursor(1, y + 1))
row, crow = self.chars[y], self.colors[y]
for x in range(self.w):
if self.use_color:
color = crow[x]
if color != current:
parts.append(color if color is not None else ANSI.RESET)
current = color
parts.append(row[x])
if self.use_color and current is not None:
parts.append(ANSI.RESET)
return ''.join(parts)
class Particles:
"""Tiny particle system for smoke, crash sparks, and stardust."""
KINDS = {
# chars are indexed by age; drift/gravity give each kind its motion
"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},
}
def __init__(self, smoke_colors: List[str]):
self.items: List[dict] = []
self.smoke_colors = smoke_colors
def emit_smoke(self, x: float, y: float):
self.items.append({
"kind": "smoke",
"x": x + random.uniform(-1, 1), "y": y,
"vx": random.uniform(0.4, 0.9), # smoke trails behind the train
"vy": -random.uniform(0.2, 0.45),
"age": 0, "life": random.randint(14, 22),
})
def emit_spark(self, x: float, y: float):
angle = random.uniform(0, 2 * math.pi)
speed = random.uniform(0.4, 1.8)
self.items.append({
"kind": "spark",
"x": x, "y": y,
"vx": math.cos(angle) * speed,
"vy": math.sin(angle) * speed * 0.6 - 0.4,
"age": 0, "life": random.randint(10, 18),
})
def emit_stardust(self, x: float, y: float):
self.items.append({
"kind": "stardust",
"x": x, "y": y + random.uniform(-1, 1),
"vx": random.uniform(0.5, 1.1),
"vy": random.uniform(-0.15, 0.15),
"age": 0, "life": random.randint(8, 14),
})
def step(self):
alive = []
for p in self.items:
spec = self.KINDS[p["kind"]]
p["x"] += p["vx"]
p["y"] += p["vy"]
p["vx"] *= spec["drag"]
p["vy"] += spec["gravity"]
p["age"] += 1
if p["age"] < p["life"]:
alive.append(p)
self.items = alive
def draw(self, screen: Screen):
for p in self.items:
spec = self.KINDS[p["kind"]]
t = p["age"] / p["life"]
chars = spec["chars"]
ch = chars[min(int(t * len(chars)), len(chars) - 1)]
if p["kind"] == "smoke":
idx = min(int(t * len(self.smoke_colors)), len(self.smoke_colors) - 1)
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:
color = ANSI.CYAN if t < 0.5 else ANSI.WHITE
screen.put(int(round(p["x"])), int(round(p["y"])), ch, color=color)
class SLAnimation:
"""Main animation controller."""
def __init__(self, train_type: str = "classic", speed: float = 1.0,
fly: bool = False, accident: bool = False):
def __init__(self, train_type: str = "classic", speed: float = 1.0,
fly: bool = False, accident: bool = False,
cars: int = 0, whistle: bool = False,
use_color: bool = True):
self.train_type = train_type
self.speed = speed
self.speed = max(0.1, min(speed, 20.0))
self.fly = fly
self.accident = accident
self.whistle = whistle
self.use_color = use_color
self.running = True
# Get terminal size
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.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)
self.smoke_colors, self.rail_color = self._palette()
self.update_terminal_size()
# Setup signal handler for window resize
signal.signal(signal.SIGWINCH, self._handle_resize)
signal.signal(signal.SIGINT, self._handle_interrupt)
if hasattr(signal, 'SIGWINCH'):
signal.signal(signal.SIGWINCH, self._handle_resize)
def _palette(self):
"""Grayscale smoke on 256-color terminals, plain white elsewhere."""
term = os.environ.get('TERM', '')
if '256' in term or os.environ.get('COLORTERM'):
smoke = [ANSI.gray(g) for g in (255, 251, 248, 245, 242)]
rail = ANSI.gray(240)
else:
smoke = [ANSI.WHITE] * 5
rail = None
return smoke, rail
def update_terminal_size(self):
"""Update terminal dimensions."""
try:
size = os.get_terminal_size()
self.width = size.columns
self.height = size.lines
except:
# Some ptys report 0x0; a degenerate size gets the default
self.width = size.columns if size.columns > 0 else 80
self.height = size.lines if size.lines > 0 else 24
except OSError:
self.width = 80
self.height = 24
def _handle_resize(self, signum, frame):
"""Handle terminal resize."""
self.update_terminal_size()
"""Handle terminal resize (applied at the next frame)."""
self.resized = True
def _handle_interrupt(self, signum, frame):
"""Handle Ctrl+C gracefully."""
self.running = False
def clear_screen(self):
"""Clear the terminal screen."""
sys.stdout.write(ANSI.CLEAR_SCREEN)
sys.stdout.flush()
@staticmethod
def _smooth(p: float) -> float:
"""Smoothstep easing for the flight path."""
return p * p * (3 - 2 * p)
def print_static(self):
"""stdout is not a terminal: print one honest train, no escapes."""
for line in self.frames[0]:
print(line.rstrip())
def run(self):
"""Run the animation."""
# Hide cursor
sys.stdout.write(ANSI.HIDE_CURSOR)
if not sys.stdout.isatty():
self.print_static()
return
sys.stdout.write(ANSI.ALT_SCREEN_ON + ANSI.HIDE_CURSOR)
sys.stdout.flush()
try:
train_lines = Train.get_train(self.train_type)
train_height = len(train_lines)
train_width = max(len(line) for line in train_lines)
# Calculate vertical position
if self.fly:
y_positions = self._calculate_fly_path(train_height)
else:
y_position = (self.height - train_height) // 2
y_positions = [y_position] * (self.width + train_width + 10)
# Animation loop
frame = 0
while self.running and frame < len(y_positions):
self.clear_screen()
# Calculate horizontal position (right to left)
x_position = self.width - frame
y_position = y_positions[frame]
# Add smoke above train
if not self.fly:
smoke_lines = SmokeGenerator.get_smoke_lines(train_width + 20, 4)
for i, smoke in enumerate(smoke_lines):
smoke_y = y_position - len(smoke_lines) + i
if smoke_y > 0:
sys.stdout.write(ANSI.move_cursor(max(1, x_position - 10), smoke_y))
sys.stdout.write(ANSI.WHITE + smoke + ANSI.RESET)
# Draw train
for i, line in enumerate(train_lines):
if 0 < y_position + i <= self.height:
# Calculate visible portion of line
if x_position < 1:
# Train is partially off-screen (left side)
start = abs(x_position) + 1
if start < len(line):
visible_line = line[start:]
sys.stdout.write(ANSI.move_cursor(1, y_position + i))
sys.stdout.write(self._colorize_line(visible_line))
elif x_position + len(line) > self.width:
# Train is partially off-screen (right side)
visible_line = line[:self.width - x_position]
sys.stdout.write(ANSI.move_cursor(x_position, y_position + i))
sys.stdout.write(self._colorize_line(visible_line))
else:
# Train is fully visible
sys.stdout.write(ANSI.move_cursor(x_position, y_position + i))
sys.stdout.write(self._colorize_line(line))
# Accident mode - show collision
if self.accident and x_position < self.width // 2:
self._show_accident(x_position, y_position, train_height)
break
sys.stdout.flush()
time.sleep(0.05 / self.speed)
frame += 1
# Stop when train is off screen
if x_position + train_width < 0:
break
self._animate()
finally:
# Clean up
self.clear_screen()
sys.stdout.write(ANSI.SHOW_CURSOR)
# The alternate screen restores whatever was there before
sys.stdout.write(ANSI.SHOW_CURSOR + ANSI.ALT_SCREEN_OFF)
sys.stdout.flush()
def _colorize_line(self, line: str) -> str:
"""Add colors to train line."""
# Simple coloring based on characters
colored = line
colored = colored.replace('D', ANSI.RED + 'D' + ANSI.RESET)
colored = colored.replace('_', ANSI.YELLOW + '_' + ANSI.RESET)
colored = colored.replace('|', ANSI.BLUE + '|' + ANSI.RESET)
colored = colored.replace('=', ANSI.GREEN + '=' + ANSI.RESET)
colored = colored.replace('O', ANSI.WHITE + 'O' + ANSI.RESET)
colored = colored.replace('o', ANSI.WHITE + 'o' + ANSI.RESET)
colored = colored.replace('~', ANSI.CYAN + '~' + ANSI.RESET)
return colored
def _calculate_fly_path(self, train_height: int) -> List[int]:
"""Calculate flying path for train."""
path = []
total_frames = self.width + 60
for i in range(total_frames):
# Sinusoidal flight path
progress = i / total_frames
y = int((self.height - train_height) * (0.5 + 0.3 *
(progress * progress * progress)))
path.append(max(1, min(self.height - train_height, y)))
return path
def _show_accident(self, x_pos: int, y_pos: int, height: int):
"""Show accident/crash effect."""
crash_art = [
" CRASH! ",
" BOOM! ",
" * * * * * ",
" * BANG!! * ",
" * * * * * "
]
for i, line in enumerate(crash_art):
if 0 < y_pos + i <= self.height:
sys.stdout.write(ANSI.move_cursor(x_pos + 10, y_pos + i))
sys.stdout.write(ANSI.RED + line + ANSI.RESET)
sys.stdout.flush()
time.sleep(2)
def _animate(self):
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 + 12
toot_until = -1
toot_marks = {int(self.width * 0.66), int(self.width * 0.33)}
next_tick = time.monotonic()
frame_i = 0
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)
x = self.width - frame_i
progress = frame_i / total_frames
if self.fly:
floor_y = max(1, self.height - self.total_h - 2)
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))
else:
y = max(1, (self.height - self.total_h) // 2)
art = self.frames[(frame_i // 2) % len(self.frames)]
# Emit particles
if self.fly:
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)
particles.step()
# Whistle as the funnel passes the marks
if self.whistle and (x + self.funnel_dx) in toot_marks:
sys.stdout.write(ANSI.BELL)
toot_until = frame_i + 8
# Compose the frame: rails, smoke, train, overlays
screen.clear()
if not self.fly:
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, y + i, line, charmap=CHAR_COLORS, opaque=True)
if frame_i < toot_until:
screen.put(x + self.funnel_dx + 3, y + self.funnel_dy - 2,
'TOOT! TOOT!', 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:
self._crash(screen, particles, x, y, art)
return
# Monotonic pacing: no drift from render time
next_tick += frame_dt
delay = next_tick - time.monotonic()
if delay > 0:
time.sleep(delay)
else:
next_tick = time.monotonic()
frame_i += 1
if x + self.total_w < 0:
break
def _crash(self, screen: Screen, particles: Particles,
x: int, y: int, art: List[str]):
"""Accident mode finale: shake, sparks, and a proper BOOM."""
messages = ['C R A S H !', 'B O O M !']
front_y = y + self.total_h // 2
for f in range(55):
if not self.running:
return
if f < 14:
for _ in range(5):
particles.emit_spark(x + random.randint(0, 8),
front_y + random.randint(-2, 2))
elif f < 40 and f % 3 == 0:
particles.emit_smoke(x + random.randint(0, 6), front_y - 2)
particles.step()
shake = f < 20
dx = random.randint(-1, 1) if shake else 0
dy = random.randint(-1, 0) if shake else 0
screen.clear()
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,
charmap=CHAR_COLORS, opaque=True)
if f < 32 and (f // 3) % 2 == 0:
color = ANSI.RED if (f // 6) % 2 == 0 else ANSI.YELLOW
screen.put(x + 6, y - 2, messages[(f // 6) % 2], color=color)
sys.stdout.write(screen.frame())
sys.stdout.flush()
time.sleep(0.06 / self.speed)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
prog='sl',
description='sl - Display animated steam locomotive',
epilog='A joke command for when you type sl instead of ls'
)
parser.add_argument('-a', '--accident', action='store_true',
help='An accident occurs')
help='an accident occurs')
parser.add_argument('-F', '--fly', action='store_true',
help='Make the train fly')
help='make the train fly')
parser.add_argument('-l', '--long', action='store_true',
help='Use a longer train')
help='use a longer train (D51 pulling coal cars)')
parser.add_argument('-c', '--C51', action='store_true',
help='Use C51 train type')
help='use C51 train type')
parser.add_argument('-t', '--type', choices=sorted(Train.TRAINS),
help='locomotive type (overrides -l/-c)')
parser.add_argument('-n', '--cars', type=int, default=0, metavar='N',
help='number of coal cars to pull (default: 0)')
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 (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',
version=f'sl version {__version__}')
args = parser.parse_args()
# Determine train type
if args.C51:
train_type = "C51"
if args.type:
train_type = args.type
elif args.C51:
train_type = "c51"
elif args.long:
train_type = "D51"
train_type = "d51"
else:
train_type = "classic"
cars = max(0, min(args.cars, 8))
if args.long and args.cars == 0:
cars = 2 # --long should actually be long
use_color = (not args.no_color
and 'NO_COLOR' not in os.environ
and sys.stdout.isatty())
# Run animation
animation = SLAnimation(
train_type=train_type,
speed=args.speed,
fly=args.fly,
accident=args.accident
accident=args.accident,
cars=cars,
whistle=args.whistle,
use_color=use_color,
)
animation.run()
if __name__ == "__main__":
main()
main()