Release 4.0.0: the Sea Update — galleon, steamer, sloop, tug, and a dolphin
The rails now end at a shoreline. Four vessels join the roster with an animated sea (swell pattern, glints, hull bob, bow spray, stern wake), a flapping Jolly Roger and turning paddle wheel on the same 4-frame machinery as the locomotive wheels, per-vessel liveries and whistles, an iceberg finale for -a at sea, the Flying Dutchman for -F, and -d, which summons a dolphin that dives ahead of the ship on the angle its art was drawn at. The tug travels diagonally for the same reason. Also ships the outstanding audit fixes: smoke lingers after the train exits, narrow-terminal crashes stay on-screen, -t fully overrides -l, make demo runs the train it advertises, --speed documents its clamp, CODE_OF_CONDUCT.md exists, LICENSE year range updated.
This commit is contained in:
parent
9b39a996b2
commit
fc828c22ca
6 changed files with 708 additions and 41 deletions
550
sl
550
sl
|
|
@ -14,6 +14,17 @@ Design notes (v3):
|
|||
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.
|
||||
|
||||
Design notes (v4, the Sea Update):
|
||||
The rails now end at a shoreline. Four vessels join the roster: a
|
||||
pirate galleon (flapping Jolly Roger, rubber-duck figurehead), a
|
||||
sternwheel steamer (the paddle wheel turns on the same 4-frame
|
||||
machinery as the locomotive wheels), a racing sloop, and a harbor tug
|
||||
drawn in three-quarter perspective -- it travels diagonally, on the
|
||||
angle its art implies, so it appears to grow as it approaches. Sea
|
||||
scenes get animated water, hull bob, bow spray, stern wake, and, on
|
||||
special request (-d), a dolphin that dives ahead of the ship. At sea,
|
||||
-a finds an iceberg, and the iceberg wins.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
|
@ -26,7 +37,7 @@ import time
|
|||
from typing import Dict, List, Optional
|
||||
|
||||
# Version
|
||||
__version__ = "3.1.0"
|
||||
__version__ = "4.0.0"
|
||||
|
||||
|
||||
# ANSI escape codes for colors and cursor control
|
||||
|
|
@ -216,6 +227,210 @@ class Train:
|
|||
return rows
|
||||
|
||||
|
||||
|
||||
class Vessel:
|
||||
"""The fleet: side-view sailing craft that ride an animated sea, plus
|
||||
the tug and the dolphin, both drawn in three-quarter perspective and
|
||||
therefore sailed on the diagonal their artwork implies."""
|
||||
|
||||
# Three-masted pirate galleon, rubber-duck figurehead, gunports
|
||||
GALLEON = [
|
||||
' |~~~~~~,',
|
||||
' ` ` ` ` |x_x__/',
|
||||
' ` ` ` ` ` | ` ` `',
|
||||
' |> | ` ` `',
|
||||
' .___|___. .___|___. |>',
|
||||
' ( | \\ ( | \\ |',
|
||||
' ( | \\ ( | \\ .____|____.',
|
||||
' (___________\\ (___________\\ ( ( | ) \\',
|
||||
' ` | | ( ( | ) \\',
|
||||
' .____|____. .____|____. ( ( | ) \\',
|
||||
' ` ( ( | ) \\ ( ( | ) \\ (__)_________(__\\',
|
||||
' ` /| ( ( | ) \\ ( ( | ) \\ |',
|
||||
' / | ( ( | ) \\ ( ( | ) \\ /| _____',
|
||||
' ` / |(__)_________(__\\ (__)_________(__\\ / | __|~ ~ ~|',
|
||||
' / | /|\\ /|\\ / || o o |',
|
||||
'<o)_/___| / | \\ / | \\ / || o o |',
|
||||
' \\__\\__________________________________________________|________|',
|
||||
' |=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=|',
|
||||
' \\ [] [] [] [] [] [] [] [] [] |',
|
||||
' \\_________S Q U E A K Y____________________________________/',
|
||||
' \\=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_/',
|
||||
]
|
||||
|
||||
# Sternwheel steamer: the wheel spokes rotate via ANIM_SPEC
|
||||
STEAMER = [
|
||||
' |==| |==| |>',
|
||||
' | | | | |',
|
||||
' / | | | | \\ .---------.',
|
||||
' / | | | | \\ | 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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue