A bare sl now randomizes train type, coal cars, color livery, and speed each run, with an occasional whistle (25%), rare flight (10%), and very rare crash (5%). Any explicit flag disables the randomness entirely, keeping all options deterministic and backward compatible. Rewrite the README around the project's actual character: surprise mode odds table, a how-it-works section on the renderer, and a FAQ. Remove broken references (missing demo gif, SECURITY.md, unverified Docker image).
631 lines
23 KiB
Python
Executable file
631 lines
23 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
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 random
|
|
import signal
|
|
import sys
|
|
import time
|
|
from typing import Dict, List, Optional
|
|
|
|
# Version
|
|
__version__ = "3.1.0"
|
|
|
|
|
|
# ANSI escape codes for colors and cursor control
|
|
class ANSI:
|
|
# Colors
|
|
RED = '\033[91m'
|
|
GREEN = '\033[92m'
|
|
YELLOW = '\033[93m'
|
|
BLUE = '\033[94m'
|
|
MAGENTA = '\033[95m'
|
|
CYAN = '\033[96m'
|
|
WHITE = '\033[97m'
|
|
RESET = '\033[0m'
|
|
|
|
# Cursor and screen control
|
|
HIDE_CURSOR = '\033[?25l'
|
|
SHOW_CURSOR = '\033[?25h'
|
|
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 (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,
|
|
}
|
|
|
|
# Alternate liveries for surprise mode
|
|
COLOR_THEMES = [
|
|
CHAR_COLORS,
|
|
{'D': ANSI.YELLOW, '_': ANSI.RED, '|': ANSI.RED,
|
|
'=': ANSI.YELLOW, 'O': ANSI.WHITE, 'o': ANSI.WHITE, '~': ANSI.MAGENTA},
|
|
{'D': ANSI.WHITE, '_': ANSI.CYAN, '|': ANSI.BLUE,
|
|
'=': ANSI.CYAN, 'O': ANSI.WHITE, 'o': ANSI.WHITE, '~': ANSI.BLUE},
|
|
{'D': ANSI.MAGENTA, '_': ANSI.GREEN, '|': ANSI.MAGENTA,
|
|
'=': ANSI.CYAN, 'O': ANSI.YELLOW, 'o': ANSI.YELLOW, '~': ANSI.GREEN},
|
|
]
|
|
|
|
|
|
def random_theme() -> Dict[str, str]:
|
|
"""A one-off livery: each character class gets a random color."""
|
|
pool = [ANSI.RED, ANSI.GREEN, ANSI.YELLOW, ANSI.BLUE,
|
|
ANSI.MAGENTA, ANSI.CYAN, ANSI.WHITE]
|
|
return {ch: random.choice(pool) for ch in CHAR_COLORS}
|
|
|
|
|
|
class Train:
|
|
"""The ASCII art trains, their animated wheel frames, and rolling stock."""
|
|
|
|
# Classic steam locomotive
|
|
CLASSIC = [
|
|
" ==== ________ ___________",
|
|
" _D _| |_______/ \\__I_I_____===__|_________|",
|
|
" |(_)--- | H\\________/ | | =|___ ___| ",
|
|
" / | | H | | | | ||_| |_|| ",
|
|
" | | | H |__--------------------| [___] | ",
|
|
" | ________|___H__/__|_____/[][]~\\_______| | ",
|
|
" |/ | |-----------I_____I [][] [] D |=======|__",
|
|
"__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__",
|
|
" |/-=|___|= || || || |_____/~\\___/ ",
|
|
" \\_/ \\O=====O=====O=====O_/ \\_/ "
|
|
]
|
|
|
|
# Small locomotive for narrow terminals
|
|
SMALL = [
|
|
" ++ +------ ",
|
|
" || |+-+ | ",
|
|
" /---------|| | | ",
|
|
" + ======== +-+ | ",
|
|
" _|--O========O~\\-+ ",
|
|
"//// \\_/ \\_/ "
|
|
]
|
|
|
|
# D51 locomotive (Japanese style)
|
|
D51 = [
|
|
" ==== ________ ___________ ",
|
|
" _D _| |_______/ \\__I_I_____===__|_________|",
|
|
" |(_)--- | H\\________/ | | =|___ ___| ",
|
|
" / | | H | | | | ||_| |_|| ",
|
|
" | | | H |__--------------------| [___] | ",
|
|
" | ________|___H__/__|_____/[][]~\\_______| | ",
|
|
" |/ | |-----------I_____I [][] [] D |=======|__",
|
|
"__/ =| o |=-O=====O=====O=====O \\ ____Y___________|__",
|
|
" |/-=|___|= || || || |_____/~\\___/ ",
|
|
" \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ "
|
|
]
|
|
|
|
# C51 locomotive
|
|
C51 = [
|
|
" ___ ",
|
|
" _|_|_ _ __ __ ___________",
|
|
" D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|",
|
|
" | `---' |:: `--' H `--' | |___ ___| ",
|
|
" +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ",
|
|
" || | :: H +=====+ | |:: ...| ",
|
|
"| | _______|_::-----------------[][]-----| | ",
|
|
"| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__",
|
|
"------'|oOo|===[]- || || | ||=======_|__",
|
|
"/~\\____|___|/~\\_| O=======O=======O |__|\\ / ",
|
|
"\\_/ \\_/ \\____/ \\____/ \\____/ \\_____/ "
|
|
]
|
|
|
|
# Coal tender, adapted from Toyoda Masashi's original sl
|
|
COAL_CAR = [
|
|
" ",
|
|
" _________________ ",
|
|
" _| \\_____A ",
|
|
" =| | ",
|
|
" -| | ",
|
|
"__|________________________|_ ",
|
|
"|__________________________|_ ",
|
|
" |_D__D__D_| |_D__D__D_| ",
|
|
" \\_/ \\_/ \\_/ \\_/ ",
|
|
]
|
|
|
|
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):
|
|
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,
|
|
cars: int = 0, whistle: bool = False,
|
|
use_color: bool = True,
|
|
palette: Optional[Dict[str, str]] = None):
|
|
self.train_type = train_type
|
|
self.speed = max(0.1, min(speed, 20.0))
|
|
self.fly = fly
|
|
self.accident = accident
|
|
self.whistle = whistle
|
|
self.use_color = use_color
|
|
self.palette = palette or CHAR_COLORS
|
|
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.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()
|
|
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()
|
|
# 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 (applied at the next frame)."""
|
|
self.resized = True
|
|
|
|
def _handle_interrupt(self, signum, frame):
|
|
"""Handle Ctrl+C gracefully."""
|
|
self.running = False
|
|
|
|
@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."""
|
|
if not sys.stdout.isatty():
|
|
self.print_static()
|
|
return
|
|
|
|
sys.stdout.write(ANSI.ALT_SCREEN_ON + ANSI.HIDE_CURSOR)
|
|
sys.stdout.flush()
|
|
try:
|
|
self._animate()
|
|
finally:
|
|
# The alternate screen restores whatever was there before
|
|
sys.stdout.write(ANSI.SHOW_CURSOR + ANSI.ALT_SCREEN_OFF)
|
|
sys.stdout.flush()
|
|
|
|
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=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)
|
|
|
|
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=self.palette, 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')
|
|
parser.add_argument('-F', '--fly', action='store_true',
|
|
help='make the train fly')
|
|
parser.add_argument('-l', '--long', action='store_true',
|
|
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('-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)')
|
|
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.type:
|
|
train_type = args.type
|
|
elif args.C51:
|
|
train_type = "c51"
|
|
elif args.long:
|
|
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())
|
|
|
|
# Surprise mode: a bare `sl` -- the classic mistyped `ls` -- rolls the
|
|
# dice on everything, so no two typos look alike. Any flag at all
|
|
# switches back to fully deterministic behavior.
|
|
palette = None
|
|
speed = args.speed
|
|
fly, accident, whistle = args.fly, args.accident, args.whistle
|
|
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()])
|
|
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
|
|
|
|
# Run animation
|
|
animation = SLAnimation(
|
|
train_type=train_type,
|
|
speed=speed,
|
|
fly=fly,
|
|
accident=accident,
|
|
cars=cars,
|
|
whistle=whistle,
|
|
use_color=use_color,
|
|
palette=palette,
|
|
)
|
|
|
|
animation.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|