Animation¶
Three small, independent additions support building an animated terminal
app on top of the screen/renderer model: a tick-loop driver, a
transparent sprite blitter, and a minimal moving entity. None of them
introduce a new abstraction over what the rest of cl-tty-kit already
gives you — they are thin helpers a caller composes with ordinary Lisp
(lists, loops) rather than a framework to opt into.
Package
All symbols are exported from cl-tty-kit. See
Getting Started for loading the system.
Deliberately out of scope
There is no z-order/layer/compositing-order concept here. Painting order
is controlled at the call site by the order sprite-blit and
screen-blit calls run in (back to front) — see
Screen and Rendering. There is likewise no
entity-component-system or particle system; entity is a plain struct,
and a collection of them is just a Lisp list your own code loops over.
The pure/impure split¶
examples/renderer-loop.lisp and examples/event-loop.lisp already
establish this repository's idiom for a loop: a pure state-advance
function (old state in, new state out, no I/O, no wall clock) kept
separate from a thin real-IO loop that paces ticks, reads input, and
writes frames. tick-loop-run and tick-loop-run-realtime are that split
made reusable, so a downstream app does not have to hand-write both a
deterministic test harness and a real loop around the same state machine.
Bounded mode: deterministic ticks¶
tick-loop-run calls a caller-supplied advance function exactly ticks
times, threading each result into the next call, and returns the final
state:
Pass :render (a function of one state, returning a frame — typically a
string from render-screen or render-diff) to also collect the sequence
of frames, one per tick, in tick order:
(tick-loop-run 0 #'1+ 3 :render (lambda (n) (format nil "tick ~D" n)))
;; => (values 3 ("tick 1" "tick 2" "tick 3"))
No real TTY, no wall clock, and no sleeping happen in this mode — it is the
mode a downstream app's own test suite should drive its game/animation loop
through, asserting on an exact final state or frame sequence the way
renderer-loop-example in examples/renderer-loop.lisp asserts on exact
rendered output.
Real-time mode: paced output¶
tick-loop-run-realtime repeatedly calls the same kind of advance
function, but paces ticks to a target frame :interval (seconds, default
1/30), writes each tick's rendered frame to a real :stream, and keeps
going until a caller-supplied stop predicate returns true:
(tick-loop-run-realtime
initial-state
#'advance-game-state
#'render-game-frame
(lambda (state) (game-state-quit-p state))
:stream *standard-output*
:interval 1/20)
stop is checked after a tick has been advanced and rendered, so the loop
always emits the frame that caused it to stop rather than swallowing it.
Pacing measures how long advance and render actually took and sleeps
only the remainder of interval, so a slow tick shortens the next sleep
instead of accumulating drift across many frames.
Both modes call the same internal per-tick step, so behavior exercised under bounded-mode tests carries over unchanged to the real-time driver — the bounded/real-time distinction is only in pacing and where output goes, never in what one tick does.
Resizing is not handled here
This repository polls terminal-size rather than trapping SIGWINCH (see
Terminal Session and Raw Mode), so
tick-loop-run-realtime does not invent a signal handler either. A
caller's own advance/render functions are the place to poll
terminal-size and react to a changed size, the same way they would
outside a tick loop.
Transparent sprite blitting¶
sprite-blit composites a block of multi-line ASCII art onto a screen,
treating a marker character (a blank space by default) as pass-through:
a transparent source cell leaves the destination cell beneath it untouched
instead of overwriting it.
(let ((screen (make-screen 6 4 :initial-cell #\.)))
(sprite-blit screen (format nil "AB~%CD") 1 1)
(screen-to-string screen))
;; => "......
;; .AB...
;; .CD...
;; ......"
This is the piece screen-blit (see
Screen and Rendering) does not
offer: screen-blit always copies every cell of its source region,
including its blanks, so it cannot by itself composite an irregular sprite
over existing content. sprite-blit is clipped to the screen the same way
screen-blit clips a region — a sprite placed partly or fully off-screen
draws only its visible overlap, on any of the four edges, rather than
signaling:
(let ((screen (make-screen 3 1 :initial-cell #\.)))
(sprite-blit screen "ABCDE" 1 0) ; runs past the right edge
(screen-row-string screen 0))
;; => ".AB"
:transparent overrides the marker character, and :style applies to every
non-transparent cell written:
A ragged line — shorter than the sprite's own widest line — is transparent past its own end, so a non-rectangular sprite does not need trailing padding.
Minimal moving entities¶
entity is a position (x, y) plus a velocity (dx, dy) and an optional
on-exit callback — nothing else. entity-tick is the only operation:
(let ((entity (make-entity :x 5 :y 5 :dx 1 :dy 0)))
(entity-tick entity 80 24)
(values (entity-x entity) (entity-y entity)))
;; => (values 6 5)
entity-tick adds the velocity to the position and, when the new position
falls outside the given width by height bounds, calls on-exit once per
violated edge — in order :left, :right, :top, :bottom — as
(funcall on-exit entity edge):
(let* ((respawned nil)
(entity (make-entity :x 0 :y 5 :dx -1 :dy 0
:on-exit (lambda (entity edge)
(declare (ignore edge))
(setf (entity-x entity) 79)
(setf respawned t)))))
(entity-tick entity 80 24)
(values (entity-x entity) respawned))
;; => (values 79 t)
An entity that exits through a corner (both an x and a y bound violated in
the same tick) fires on-exit twice, once per edge. width and height
are passed to each entity-tick call rather than stored on the entity, so a
caller can check against a freshly polled terminal-size every tick.
Managing more than one entity is ordinary Lisp: a list of entity structs,
mapc'd over with entity-tick once per game tick, is the whole pattern —
cl-tty-kit does not add a container type for this.
Putting it together¶
A caller's advance function for tick-loop-run/tick-loop-run-realtime
typically: calls entity-tick on each live entity (with an on-exit that
removes or repositions it), clears and redraws a screen with screen-fill
and one sprite-blit per entity in back-to-front order, and returns the new
state. The render function then hands that screen to render-diff or a
renderer (see Screen and Rendering) to produce
the frame tick-loop-run/tick-loop-run-realtime collects or streams.
See also¶
- Screen and Rendering —
screen-blit, the double-bufferedrenderer, and the render/diff functions that turn a screen into ANSI. - Examples —
examples/renderer-loop.lispandexamples/event-loop.lisp, the pure/impure split this file's tick loop generalizes. - Terminal Session and Raw Mode —
terminal-size, polled by a caller's own loop rather than bytick-loop-run-realtime. - API Reference — the complete exported symbol list.