luv

Workshop wiki

marquee.lisp

luvcraft/birthday/marquee.lisp

system luvcraft/birthday · 13 definitions · on GitHub

The marquee: a terminal wall wearing the birthday's name in lights.

A rectangle of terminal blocks mounts over the gazebo's entrance, and a birthday-marquee-display -- an ordinary Ghostty terminal wall -- shows big block letters scrolling across it, figlet-fashion, in rainbow ink with chase lights above and below. No pty is involved: the marquee writes ANSI frames straight into its own Ghostty emulator and marks itself dirty, and the wall's stock refresh path re-snapshots the screen and republishes the glyphs, exactly as it would for a shell's output. The wall's ink is emissive above the lens's bright-pass threshold, so the letters glow into the dusk on their own.

in-package#:luvcraft.birthday

--------------------------------------------------------------------- The letterforms

Five-by-seven bitmaps for the characters a birthday banner needs; each pixel is drawn two cells wide so the letters read square on a terminal grid. Anything the font does not know renders as blank space, which for a marquee is the right kind of failure.

defparameter*marquee-font*'
#\A" XXX ""X X""X X""XXXXX""X X""X X""X X"
#\L"X ""X ""X ""X ""X ""X ""XXXXX"
#\E"XXXXX""X ""X ""XXXX ""X ""X ""XXXXX"
#\X"X X""X X"" X X "" X "" X X ""X X""X X"
#\D"XXXX ""X X""X X""X X""X X""X X""XXXX "
#\N"X X""XX X""X X X""X XX""X X""X X""X X"
#\I"XXXXX"" X "" X "" X "" X "" X ""XXXXX"
#\0" XXX ""X X""X XX""X X X""XX X""X X"" XXX "
#\1" X "" XX "" X "" X "" X "" X ""XXXXX"
#\2" XXX ""X X"" X"" X "" X "" X ""XXXXX"
#\3"XXXX "" X"" X"" XXX "" X"" X""XXXX "
#\4" X "" XX "" X X ""X X ""XXXXX"" X "" X "
#\5"XXXXX""X ""X ""XXXX "" X"" X""XXXX "
#\6" XXX ""X ""X ""XXXX ""X X""X X"" XXX "
#\7"XXXXX"" X"" X "" X "" X "" X "" X "
#\8" XXX ""X X""X X"" XXX ""X X""X X"" XXX "
#\9" XXX ""X X""X X"" XXXX"" X"" X"" XXX "
#\Space" "" "" "" "" "" "" "
"Rows of X-and-space pixels, seven per glyph."
defunmarquee-banner-rows
text

Render text through the marquee font into seven strings of pixels.

let
rows
make-array+marquee-glyph-rows+:initial-element""
loopforcharacteracross
string-upcasetext
forbitmap=
rest
assoccharacter*marquee-font*
whenbitmapdo
dotimes
setf
arefrowsrow
concatenate'string
arefrowsrow
nthrowbitmap
" "

A dozen blank columns end the cycle, so the sign goes briefly dark between one pass of the name and the next instead of chasing its own tail around the wrap.

let
gap
make-string12:initial-element#\Space
loopforrowbelow+marquee-glyph-rows+do
setf
arefrowsrow
concatenate'string
arefrowsrow
gap
coercerows'list

--------------------------------------------------------------------- The frames

A frame is one full ANSI repaint: clear, home, then every row. The banner scrolls right to left with wraparound; ink hue drifts with the scroll so the rainbow rides the letters; the light strips above and below chase the other way. Colour changes are emitted only at column boundaries where they change, which keeps a frame to a few kilobytes.

defunmarquee-hue-color
hue

hue in turns to (values red green blue), bright and saturated.

flet
lobe
offset
let
angle
*2pi
-hueoffset
round
*255
min1.0
max0.0
+0.55
values
lobe0.0
lobe1/3
lobe2/3
defunwrite-marquee-ink
streamredgreenblue
formatstream"~C[38;2;~D;~D;~Dm"
code-char27
redgreenblue
defunwrite-marquee-lights-row
streamcolumnsphase

One row of chase lights: every fourth cell lit, walking with phase.

let
last-inknil
dotimes
columncolumns
if
zerop
mod
+columnphase
4
multiple-value-bind
redgreenblue
marquee-hue-color
/
mod
+column
*3phase
24
24.0
let
ink
listredgreenblue
unless
equalinklast-ink
write-marquee-inkstreamredgreenblue
setflast-inkink
write-char#\FULL_BLOCKstream
write-char#\Spacestream
defunmarquee-frame-string
banner-rowsbanner-widthcolumnsrowsphase

The whole ANSI frame for the marquee at scroll phase.

Every row is cursor-addressed rather than newline-separated: a newline after the last row of an exactly-filled screen scrolls it, which is how the sign's top row of lights once quietly walked off the wall.

let
escape
code-char27
with-output-to-string
stream
formatstream"~C[2J"escape
flet
at-row
row
formatstream"~C[~D;1H"escaperow

The upper light strip, the letters centred between the strips, and the lower strip chasing the other way.

at-row1
write-marquee-lights-rowstreamcolumnsphase
loopforpixelsinbanner-rowsforrowfrom
+2top-gap
do
at-rowrow
let
last-inknil
dotimes
columncolumns

Two screen cells per font pixel, scrolling leftward.

let*
pixel-column
mod
+
floorcolumn2
phase
banner-width
on
and
<pixel-column
lengthpixels
char=
charpixelspixel-column
#\X
ifon
multiple-value-bind
redgreenblue
marquee-hue-color
/
mod
-pixel-columnphase
36
36.0
let
ink
listredgreenblue
unless
equalinklast-ink
write-marquee-inkstreamredgreenblue
setflast-inkink
write-char#\FULL_BLOCKstream
write-char#\Spacestream
at-rowrows
write-marquee-lights-rowstreamcolumns
-phase
formatstream"~C[0m"escape

--------------------------------------------------------------------- The display

defparameter*marquee-tick-seconds*0.09

How often the marquee advances one pixel column: about eleven a second, the pace of a sign worth reading rather than a strobe.

defclassbirthday-marquee-display
text:initform"ALEX 4":accessormarquee-text
banner-rows:initformnil:accessormarquee-banner-cache
banner-width:initform0:accessormarquee-banner-width
phase:initform0:accessormarquee-phase
clock:initformnil:accessormarquee-clock
defun
setfmarquee-banner
textdisplay
let
setf
marquee-textdisplay
text
marquee-banner-cachedisplay
rows
marquee-banner-widthdisplay
length
firstrows
text
defmethodluvcraft::refresh-luvcraft-overlay:before

The wall clock, not the session clock: the sign keeps its own beat the way the tape orb does, clamped so a stall does not fast-forward it.

let*
now
/
get-internal-real-time
floatinternal-time-units-per-second
then
or
marquee-clockdisplay
now
when
or
null
marquee-clockdisplay
setf
marquee-clockdisplay
now
incf
marquee-phasedisplay
let
domain
luvcraft::terminal-grid-presentation-domain
luvcraft::terminal-display-presentationdisplay
ghostty:write-terminal
luvcraft::terminal-display-terminaldisplay
marquee-frame-string
marquee-banner-cachedisplay
marquee-banner-widthdisplay
luvcraft::terminal-grid-domain-columnsdomain
luvcraft::terminal-grid-domain-rowsdomain
marquee-phasedisplay
setf
luvcraft::terminal-display-dirty-pdisplay
t

--------------------------------------------------------------------- Mounting it

defunadd-birthday-marquee
session&key
text"ALEX 4"
origin'
-415-9
columns72
rows11

Mount the marquee wall and start its animation; returns the display.

Places a width by height rectangle of terminal blocks at origin facing -z (the :back face), discovers it as a terminal surface, and dresses it in a marquee display showing text. Placing the same blocks again on a reopened world is a no-op edit, so mounting is idempotent.

destructuring-bind
xyz
origin
let
world
luvcraft:luvcraft-session-worldsession
multiple-value-bind
surfacestatus
unlesssurface
error"The marquee wall did not become a surface: ~S."status
let
display
luvcraft::make-terminal-displaysessionsurfacecolumnsrows:class'birthday-marquee-display:fixture
formatnil"~C[2J"
code-char27
setf
marquee-bannerdisplay
text
display
defunremove-birthday-marquee
session

Take the marquee display down, leaving the wall blocks standing.

dolist
overlay
luvcraft::luvcraft-session-overlayssession