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
50
CHANGELOG.md
50
CHANGELOG.md
|
|
@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [3.1.0] - 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
65
CODE_OF_CONDUCT.md
Normal file
65
CODE_OF_CONDUCT.md
Normal file
|
|
@ -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.
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
12
Makefile
12
Makefile
|
|
@ -32,12 +32,22 @@ test:
|
||||||
# Run with different options
|
# Run with different options
|
||||||
demo:
|
demo:
|
||||||
@echo "=== Classic Train ==="
|
@echo "=== Classic Train ==="
|
||||||
./sl
|
./sl -t classic
|
||||||
@echo "=== Flying Train ==="
|
@echo "=== Flying Train ==="
|
||||||
./sl -F
|
./sl -F
|
||||||
@echo "=== C51 Train ==="
|
@echo "=== C51 Train ==="
|
||||||
./sl -c
|
./sl -c
|
||||||
@echo "=== Accident ==="
|
@echo "=== Accident ==="
|
||||||
./sl -a
|
./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
|
.PHONY: all install uninstall test demo
|
||||||
70
README.md
70
README.md
|
|
@ -43,17 +43,68 @@ Very rarely, it does not make it across.
|
||||||
|
|
||||||
| Event | Odds |
|
| 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 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
|
Pass any flag and the dice are off — explicit options are fully
|
||||||
deterministic, so your customizations always behave exactly as written.
|
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 |
|
||||||
|
\__\__________________________________________________|________|
|
||||||
|
|=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=|
|
||||||
|
\ [] [] [] [] [] [] [] [] [] |
|
||||||
|
\_________S Q U E A K Y____________________________________/
|
||||||
|
\=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_=_/
|
||||||
|
```
|
||||||
|
|
||||||
|
The fleet:
|
||||||
|
|
||||||
|
- 🏴☠️ **`galleon`** — the Jolly Roger flaps on a 4-frame cycle, pennants
|
||||||
|
flick, and the sea shimmers under her hull
|
||||||
|
- 🛞 **`steamer`** — a sternwheeler whose paddle wheel actually turns
|
||||||
|
(same 4-frame machinery as the locomotive wheels), churn streaming aft
|
||||||
|
- ⛵ **`sloop`** — a racing yacht for narrow terminals
|
||||||
|
- 🚢 **`tug`** — drawn in three-quarter perspective, so it travels
|
||||||
|
*diagonally*, on the angle its art implies, growing as it approaches
|
||||||
|
|
||||||
|
Sea voyages come with bow spray, stern wake, hull bob, and per-vessel
|
||||||
|
whistles (`-w`): the steamer HOOOONKs, the galleon goes YARRR!. At sea,
|
||||||
|
`-a` finds an iceberg — crunch, bubbles, and a dignified descent. `-F`
|
||||||
|
is the Flying Dutchman. And on special request, `-d` summons a dolphin
|
||||||
|
that dives ahead of the ship, cut cleanly at the waterline.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 🎲 **Surprise mode** — a bare `sl` randomizes the whole show, every run
|
- 🎲 **Surprise mode** — a bare `sl` randomizes the whole show, every run
|
||||||
- 🚂 **Four locomotives** — Classic, Small, D51, and C51 (`-t`)
|
- 🚂 **Four locomotives** — Classic, Small, D51, and C51 (`-t`)
|
||||||
|
- ⚓ **Four vessels** — Galleon, Steamer, Sloop, and Tug (`-t`), with an
|
||||||
|
animated sea, spray, wake, and a dolphin on request (`-d`)
|
||||||
- 🛞 **Animated wheels** — a 4-frame rotation cycle on every engine
|
- 🛞 **Animated wheels** — a 4-frame rotation cycle on every engine
|
||||||
- 💨 **Particle smoke** — drifts behind the train and dissipates, with grayscale shading on 256-color terminals
|
- 💨 **Particle smoke** — drifts behind the train and dissipates, with grayscale shading on 256-color terminals
|
||||||
- 🚃 **Coal cars** — couple up to 8 tenders behind the engine (`-n`)
|
- 🚃 **Coal cars** — couple up to 8 tenders behind the engine (`-n`)
|
||||||
|
|
@ -120,9 +171,10 @@ $ sl -s 0.1 # You do not
|
||||||
| `-F` | `--fly` | Make the train fly through the sky |
|
| `-F` | `--fly` | Make the train fly through the sky |
|
||||||
| `-l` | `--long` | Use a longer train (D51 pulling coal cars) |
|
| `-l` | `--long` | Use a longer train (D51 pulling coal cars) |
|
||||||
| `-c` | `--C51` | Use the C51 train type |
|
| `-c` | `--C51` | Use the C51 train type |
|
||||||
| `-t` | `--type` | Locomotive type: `classic`, `small`, `d51`, `c51` (overrides `-l`/`-c`) |
|
| `-t` | `--type` | Type: `classic`, `small`, `d51`, `c51`, `galleon`, `steamer`, `sloop`, `tug` (overrides `-l`/`-c`) |
|
||||||
| `-n` | `--cars` | Number of coal cars to pull, up to 8 (default: 0) |
|
| `-n` | `--cars` | Number of coal cars to pull, up to 8 (default: 0; rail only — coal cars do not float) |
|
||||||
| `-w` | `--whistle` | Sound the whistle as the train passes |
|
| `-d` | `--dolphin` | A dolphin joins the voyage (implies a vessel) |
|
||||||
|
| `-w` | `--whistle` | Sound the whistle as the train or ship passes |
|
||||||
| `-s` | `--speed` | Animation speed multiplier, 0.1–20 (default: 1.0) |
|
| `-s` | `--speed` | Animation speed multiplier, 0.1–20 (default: 1.0) |
|
||||||
| | `--no-color` | Disable colors (`NO_COLOR` is also honored) |
|
| | `--no-color` | Disable colors (`NO_COLOR` is also honored) |
|
||||||
| `-v` | `--version` | Show version information |
|
| `-v` | `--version` | Show version information |
|
||||||
|
|
@ -173,6 +225,14 @@ of surprise mode, and honestly, it's a little bit on you for typing `sl`.
|
||||||
**Why would a train fly?**
|
**Why would a train fly?**
|
||||||
1-in-10 odds say you'll find out.
|
1-in-10 odds say you'll find out.
|
||||||
|
|
||||||
|
**I asked for a dolphin and got a ship.**
|
||||||
|
A dolphin will not follow a train. Requesting one books sea passage.
|
||||||
|
|
||||||
|
**Why does the tug move diagonally?**
|
||||||
|
It was drawn in three-quarter perspective, so it sails the angle its
|
||||||
|
art implies — toward you. It appears to grow because it is, in the
|
||||||
|
only sense that matters, getting closer.
|
||||||
|
|
||||||
**Is this compatible with the original `sl`?**
|
**Is this compatible with the original `sl`?**
|
||||||
The spirit, the D51/C51 art heritage, and the `-a`/`-F`/`-l`/`-c` flags are
|
The spirit, the D51/C51 art heritage, and the `-a`/`-F`/`-l`/`-c` flags are
|
||||||
all honored. The renderer, particles, and surprise mode are new.
|
all honored. The renderer, particles, and surprise mode are new.
|
||||||
|
|
|
||||||
532
sl
532
sl
|
|
@ -14,6 +14,17 @@ Design notes (v3):
|
||||||
coal cars, a whistle, and a crash that earns the -a flag. When stdout
|
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,
|
is not a terminal, a static train is printed instead of escape codes,
|
||||||
so `sl | cat` stays a train and not a mess.
|
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
|
import argparse
|
||||||
|
|
@ -26,7 +37,7 @@ import time
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
# Version
|
# Version
|
||||||
__version__ = "3.1.0"
|
__version__ = "4.0.0"
|
||||||
|
|
||||||
|
|
||||||
# ANSI escape codes for colors and cursor control
|
# ANSI escape codes for colors and cursor control
|
||||||
|
|
@ -216,6 +227,210 @@ class Train:
|
||||||
return rows
|
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:
|
class Screen:
|
||||||
"""Double-buffered frame composer: draw everything into an off-screen
|
"""Double-buffered frame composer: draw everything into an off-screen
|
||||||
cell buffer, then emit the whole frame as one write. No per-frame
|
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},
|
"smoke": {"chars": "@@Oo*..", "gravity": 0.0, "drag": 0.98},
|
||||||
"spark": {"chars": "@**+x..", "gravity": 0.12, "drag": 1.0},
|
"spark": {"chars": "@**+x..", "gravity": 0.12, "drag": 1.0},
|
||||||
"stardust": {"chars": "**++...", "gravity": 0.0, "drag": 0.99},
|
"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]):
|
def __init__(self, smoke_colors: List[str]):
|
||||||
|
|
@ -321,6 +539,36 @@ class Particles:
|
||||||
"age": 0, "life": random.randint(8, 14),
|
"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):
|
def step(self):
|
||||||
alive = []
|
alive = []
|
||||||
for p in self.items:
|
for p in self.items:
|
||||||
|
|
@ -345,7 +593,11 @@ class Particles:
|
||||||
color = self.smoke_colors[idx]
|
color = self.smoke_colors[idx]
|
||||||
elif p["kind"] == "spark":
|
elif p["kind"] == "spark":
|
||||||
color = ANSI.WHITE if t < 0.3 else (ANSI.YELLOW if t < 0.6 else ANSI.RED)
|
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
|
color = ANSI.CYAN if t < 0.5 else ANSI.WHITE
|
||||||
screen.put(int(round(p["x"])), int(round(p["y"])), ch, color=color)
|
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,
|
fly: bool = False, accident: bool = False,
|
||||||
cars: int = 0, whistle: bool = False,
|
cars: int = 0, whistle: bool = False,
|
||||||
use_color: bool = True,
|
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.train_type = train_type
|
||||||
self.speed = max(0.1, min(speed, 20.0))
|
self.speed = max(0.1, min(speed, 20.0))
|
||||||
self.fly = fly
|
self.fly = fly
|
||||||
|
|
@ -368,15 +621,38 @@ class SLAnimation:
|
||||||
self.running = True
|
self.running = True
|
||||||
self.resized = False
|
self.resized = False
|
||||||
|
|
||||||
|
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)
|
base = Train.TRAINS.get(train_type, Train.CLASSIC)
|
||||||
self.frames = [Train.couple(f, cars) for f in Train.frames(train_type)]
|
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_w = max(len(r) for r in self.frames[0])
|
||||||
self.total_h = len(self.frames[0])
|
self.total_h = len(self.frames[0])
|
||||||
|
if self.scene == 'rail':
|
||||||
# If a coal car is taller than the loco, the loco is padded down
|
# If a coal car is taller than the loco, the loco is padded down
|
||||||
self.funnel_dy = self.total_h - len(base)
|
self.funnel_dy = self.total_h - len(base)
|
||||||
self.funnel_dx = Train.FUNNEL_X.get(train_type, 7)
|
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.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()
|
self.update_terminal_size()
|
||||||
signal.signal(signal.SIGINT, self._handle_interrupt)
|
signal.signal(signal.SIGINT, self._handle_interrupt)
|
||||||
|
|
@ -419,9 +695,11 @@ class SLAnimation:
|
||||||
return p * p * (3 - 2 * p)
|
return p * p * (3 - 2 * p)
|
||||||
|
|
||||||
def print_static(self):
|
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]:
|
for line in self.frames[0]:
|
||||||
print(line.rstrip())
|
print(line.rstrip())
|
||||||
|
if self.is_vessel:
|
||||||
|
print('~' * self.total_w)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the animation."""
|
"""Run the animation."""
|
||||||
|
|
@ -432,6 +710,9 @@ class SLAnimation:
|
||||||
sys.stdout.write(ANSI.ALT_SCREEN_ON + ANSI.HIDE_CURSOR)
|
sys.stdout.write(ANSI.ALT_SCREEN_ON + ANSI.HIDE_CURSOR)
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
try:
|
try:
|
||||||
|
if self.scene == 'sea':
|
||||||
|
self._animate_sea()
|
||||||
|
else:
|
||||||
self._animate()
|
self._animate()
|
||||||
finally:
|
finally:
|
||||||
# The alternate screen restores whatever was there before
|
# The alternate screen restores whatever was there before
|
||||||
|
|
@ -461,6 +742,13 @@ class SLAnimation:
|
||||||
y = int(2 + (floor_y - 2) * (1 - self._smooth(progress))
|
y = int(2 + (floor_y - 2) * (1 - self._smooth(progress))
|
||||||
+ 1.5 * math.sin(progress * 7))
|
+ 1.5 * math.sin(progress * 7))
|
||||||
y = max(1, min(self.height - self.total_h - 1, y))
|
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:
|
else:
|
||||||
y = max(1, (self.height - self.total_h) // 2)
|
y = max(1, (self.height - self.total_h) // 2)
|
||||||
|
|
||||||
|
|
@ -471,7 +759,8 @@ class SLAnimation:
|
||||||
if frame_i % 2 == 0:
|
if frame_i % 2 == 0:
|
||||||
particles.emit_stardust(x + self.total_w, y + self.total_h - 2)
|
particles.emit_stardust(x + self.total_w, y + self.total_h - 2)
|
||||||
if frame_i % 2 == 0:
|
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()
|
particles.step()
|
||||||
|
|
||||||
# Whistle as the funnel passes the marks
|
# Whistle as the funnel passes the marks
|
||||||
|
|
@ -481,7 +770,7 @@ class SLAnimation:
|
||||||
|
|
||||||
# Compose the frame: rails, smoke, train, overlays
|
# Compose the frame: rails, smoke, train, overlays
|
||||||
screen.clear()
|
screen.clear()
|
||||||
if not self.fly:
|
if not self.fly and self.scene == 'rail':
|
||||||
screen.put(0, y + self.total_h, '-' * self.width,
|
screen.put(0, y + self.total_h, '-' * self.width,
|
||||||
color=self.rail_color)
|
color=self.rail_color)
|
||||||
particles.draw(screen)
|
particles.draw(screen)
|
||||||
|
|
@ -489,13 +778,15 @@ class SLAnimation:
|
||||||
screen.put(x, y + i, line, charmap=self.palette, opaque=True)
|
screen.put(x, y + i, line, charmap=self.palette, opaque=True)
|
||||||
if frame_i < toot_until:
|
if frame_i < toot_until:
|
||||||
screen.put(x + self.funnel_dx + 3, y + self.funnel_dy - 2,
|
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.write(screen.frame())
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
# The -a flag: the train makes it halfway, and no further
|
# The -a flag: the train makes it halfway, and no further.
|
||||||
if self.accident and x <= (self.width - self.total_w) // 2:
|
# 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)
|
self._crash(screen, particles, x, y, art)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -508,8 +799,176 @@ class SLAnimation:
|
||||||
next_tick = time.monotonic()
|
next_tick = time.monotonic()
|
||||||
frame_i += 1
|
frame_i += 1
|
||||||
|
|
||||||
if x + self.total_w < 0:
|
def _draw_sea(self, screen: Screen, water_y: int, frame_i: int):
|
||||||
break
|
"""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,
|
def _crash(self, screen: Screen, particles: Particles,
|
||||||
x: int, y: int, art: List[str]):
|
x: int, y: int, art: List[str]):
|
||||||
|
|
@ -532,6 +991,7 @@ class SLAnimation:
|
||||||
dy = random.randint(-1, 0) if shake else 0
|
dy = random.randint(-1, 0) if shake else 0
|
||||||
|
|
||||||
screen.clear()
|
screen.clear()
|
||||||
|
if self.scene == 'rail':
|
||||||
screen.put(0, y + self.total_h, '-' * self.width,
|
screen.put(0, y + self.total_h, '-' * self.width,
|
||||||
color=self.rail_color)
|
color=self.rail_color)
|
||||||
particles.draw(screen)
|
particles.draw(screen)
|
||||||
|
|
@ -563,14 +1023,19 @@ def main():
|
||||||
help='use a longer train (D51 pulling coal cars)')
|
help='use a longer train (D51 pulling coal cars)')
|
||||||
parser.add_argument('-c', '--C51', action='store_true',
|
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),
|
parser.add_argument('-t', '--type',
|
||||||
help='locomotive type (overrides -l/-c)')
|
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',
|
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',
|
parser.add_argument('-w', '--whistle', action='store_true',
|
||||||
help='sound the whistle as the train passes')
|
help='sound the whistle as the train passes')
|
||||||
parser.add_argument('-s', '--speed', type=float, default=1.0,
|
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',
|
parser.add_argument('--no-color', action='store_true',
|
||||||
help='disable colors (NO_COLOR is also honored)')
|
help='disable colors (NO_COLOR is also honored)')
|
||||||
parser.add_argument('-v', '--version', action='version',
|
parser.add_argument('-v', '--version', action='version',
|
||||||
|
|
@ -578,19 +1043,25 @@ def main():
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Determine train type
|
# Determine what rolls out (or sets sail)
|
||||||
if args.type:
|
if args.type:
|
||||||
train_type = args.type
|
craft = args.type
|
||||||
elif args.C51:
|
elif args.C51:
|
||||||
train_type = "c51"
|
craft = "c51"
|
||||||
elif args.long:
|
elif args.long:
|
||||||
train_type = "d51"
|
craft = "d51"
|
||||||
else:
|
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))
|
cars = max(0, min(args.cars, 8))
|
||||||
if args.long and args.cars == 0:
|
if args.long and not args.type and args.cars == 0:
|
||||||
cars = 2 # --long should actually be long
|
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
|
use_color = (not args.no_color
|
||||||
and 'NO_COLOR' not in os.environ
|
and 'NO_COLOR' not in os.environ
|
||||||
|
|
@ -602,8 +1073,14 @@ def main():
|
||||||
palette = None
|
palette = None
|
||||||
speed = args.speed
|
speed = args.speed
|
||||||
fly, accident, whistle = args.fly, args.accident, args.whistle
|
fly, accident, whistle = args.fly, args.accident, args.whistle
|
||||||
|
dolphin = args.dolphin
|
||||||
if len(sys.argv) == 1:
|
if len(sys.argv) == 1:
|
||||||
train_type = random.choice(list(Train.TRAINS))
|
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])
|
cars = random.choice([0, 0, 0, 1, 2, 2, 3, 4, 8])
|
||||||
palette = random.choice(COLOR_THEMES + [random_theme()])
|
palette = random.choice(COLOR_THEMES + [random_theme()])
|
||||||
speed = random.uniform(0.8, 1.5)
|
speed = random.uniform(0.8, 1.5)
|
||||||
|
|
@ -612,9 +1089,13 @@ def main():
|
||||||
accident = roll < 0.05 # rare: the typo ends in tragedy
|
accident = roll < 0.05 # rare: the typo ends in tragedy
|
||||||
fly = 0.05 <= roll < 0.15 # rare: the typo takes flight
|
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
|
# Run animation
|
||||||
animation = SLAnimation(
|
animation = SLAnimation(
|
||||||
train_type=train_type,
|
train_type=craft,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
fly=fly,
|
fly=fly,
|
||||||
accident=accident,
|
accident=accident,
|
||||||
|
|
@ -622,6 +1103,7 @@ def main():
|
||||||
whistle=whistle,
|
whistle=whistle,
|
||||||
use_color=use_color,
|
use_color=use_color,
|
||||||
palette=palette,
|
palette=palette,
|
||||||
|
dolphin=dolphin,
|
||||||
)
|
)
|
||||||
|
|
||||||
animation.run()
|
animation.run()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue