A drafter widget that draws a Cauldron2D surface.
{:cauldron_surface,
[
id: :map,
atlas: :my_game,
focus: {x, y},
cell: &MyGame.cell/1,
movers: [{:ball, {7.42, 3.15}}],
flex: 1
]}Register it before use, after loading it:
Code.ensure_loaded!(Cauldron2D.Drafter.Surface)
Drafter.Widget.Registry.register(Cauldron2D.Drafter.Surface)Where Cauldron2D.Renderer finds a pixel protocol, image/3 hands drafter one composited
image and render/2 fills the pane with blank cells beneath it. Otherwise image/3 returns
nil and render/2 draws two-cell glyphs. Both read the same camera view, sized from the
pane by Cauldron2D.Camera.columns_for/1 and Cauldron2D.Camera.rows_for/1.
Until both :focus and :cell are set, render/2 fills the pane with black and image/3
returns nil.
Options
:atlas— the name an atlas was installed under withCauldron2D.Atlas.install/1:focus,:cell— passed toCauldron2D.Camera.view/1:movers—[{art, {x, y}}]or[{art, {x, y}, heading}]in world tile coordinates, which may be fractional,headingin radians. Default[]. In pixel mode a mover the size of a tile is drawn at its exact pixel position and a larger one stands in that cell, centred across it with its bottom edge on the cell's bottom, asCauldron2D.Surfacestands a tall overlay; in text mode it replaces the glyph of the nearest whole cell; every projection but the plain glyph one poses it by its heading, showing the art's:front,:backor:sideand mirroring it when it faces left:volumes—[%{at: {x, y}, shape: shape, colour: {red, green, blue}, strength: s}]in world tile coordinates: coloured regions of space tinting what is seen through them, asCauldron2D.Projection.Volumedescribes them. Every projection draws them, and in text mode they tint the glyphs they cover. Default[]:labels—[{text, {x, y}, colour}]or[{text, {x, y}, colour, opts}]in world tile coordinates, asCauldron2D.Surface.compose/4takes them: names, numbers, notes drawn over everything. Default[]. In pixel mode a label is drawn inLinocut.Fontat its exact pixel position, with placements as a stored image of its own; in text mode its characters overlay the glyphs from the nearest cell:zoom— how many times its native size a tile is drawn:2twice as large,0.5half, so a pane shows half or twice the tiles. Default1. Levels a surface draws exactly arezoom_levels/2's; another is drawn at the nearest of them. Zoomed in, a pixel terminal that can fit an image to cells is sent the smaller picture to scale, sixels are scaled first; zoomed out, the frame is composed at the lower resolution. In text mode a tile zoomed in covers that many more cells; zoomed out, every that-manyeth tile is drawn. Movers are placed by the terminal (placements) only at native size:mode— aCauldron2D.Renderermode to force instead of detecting one;:braille: the frame composed at four dots per tile side, pixels at or above a luminance of 64 lit, drawn as braille text in the colour of each cell's lit pixels; or aFrenchCurve.Backend.Cellsglyph set,:half,:quad,:sextantor:octant: the frame composed at twice the set's pixels per cell across a tile, drawn as block characters in two colours per cell. Defaultnil:compress— zlib the kitty transmit. Defaulttrue:placements—:auto(default),trueorfalse. Whether the pane is drawn from images the terminal stores between frames rather than one image composited every frame; see below:projection—:top(default),:isoor:first_person; see below:picture—fn {width, height}, aspect -> raster end: a picture the game draws itself, in place of the atlas world, at the pane's size in pixels;aspectis how wide each pixel is on screen against its height. Given, the surface draws it in every mode — through the terminal's pixel protocol, or as block glyphs — and:projectionis:picture;:focus,:celland:moversare not read:native—{width, height}, the picture's own size in pixels. Given, on a kitty or iTerm2 terminal the picture is asked for at that size times a whole number (:native_scale), widened or heightened to the pane's shape, and the terminal scales it to the pane, in place of the pane's cells times the density. Defaultnil:native_scale—:fit, the largest whole number at which the picture fits the pane's pixels (the terminal's cell size times the pane's cells, where the terminal reports one, else the cells times the density), or a positive integer. Default:fit:picture_format—:rgba(default), deflated raw pixels; or:png, a PNG, palette when the frame has at most 256 colours (FrenchCurve.Backend.Kitty.encode/2), on a kitty terminal that keeps images:swap—truestores each frame under the one of two kitty image ids not on screen, places it and only then deletes the other, as one synchronized update, so the pane is never blank between frames, on a kitty terminal that keeps images. Defaultfalse:facing— which step of a turn the isometric view is at,0tofacings - 1. A fraction is allowed and turns the view by part of a step. Default0:facings— how many steps make a full turn, as a number for the isometric view or a map per projection,%{iso: 8, oblique: 4}. The oblique view is fixed at its default of one step, and takes1or4. For the isometric view, at least4. At4the view is drawn by blitting one stamp per art, which is the cheapest path; any other number, or a fractional:facing, draws the ground and the standing arts by sampling, which costs several times as much and is seamless at every angle. Default4:tilt— the isometric diamond's height as a fraction of the tile,0.5..1.5. Default1.0:heading— radians, where the first-person eye looks,0along+x. Default0.0:eye— how far above the ground the camera sits, in tiles, where the projection puts it in the world: first person and mode 7. An art gives its own withCauldron2D.Atlas.eye_height/2, so a taller figure sees further over the world. Defaultnil, the projection's own:pitch—-1.0..1.0, how far the first-person horizon is moved. Default0.0:density— how many pixels a terminal cell is drawn with in a projected view on a terminal that scales pictures to cells, before the zoom divides it. More pixels show more of a sprite's own detail; what that costs depends on the projection, because the isometric and oblique views blit whole sprites while first person and mode 7 sample every pixel. A map from projection to number sets each separately, which is how a game keeps a crisp isometric view without paying for it in first person. Default: half the atlas's tile size, and4in first person and mode 7:on_geometry— an event name sent to the application whenever the pane's rect changes, seeon_rect_change/2. Defaultnil:image_throttle,:image_priority— how often a new frame is composed and at what scheduler priority, as drafter's widget server takes them. Defaults: every second application frame,:low. A game animating at the application's frame rate passesimage_throttle: {1, :tick}, image_priority: :normal
Projections
:iso draws Cauldron2D.Projection.Iso with the focus at the pane's centre: floors as
diamonds, arts with a height as blocks, movers and flat overlays upright, turned by
:facing and tilted by :tilt; zoomed out it shrinks the whole picture. :first_person
draws Cauldron2D.Projection.FirstPerson from the focus along :heading, with
:pitch. Both are drawn as an image on a pixel terminal and as block characters or
braille otherwise; plain glyphs (:text) stay top-down. Placements are not used.
world_at/3 maps a pane cell back to the ground for the top and isometric views.
Scrolling
In both pixel renderings the focus sits at the exact centre of the pane: the grid is composed one tile wider and taller than the pane and shown through a window shifted by the fraction of a tile the focus is past a tile boundary, and movers are placed relative to that same fractional origin. Text mode scrolls by whole tiles.
Animation
An art with frames (Cauldron2D.Atlas.put/4 with a list) is drawn at the frame showing
on the session's monotonic clock, in every rendering but glyphs. The grid image of the
placement rendering is sent again when a frame changes; a mover with frames is placed
by the frame's own stored image.
Placements
On a kitty terminal whose cell size the session knows
(Drafter.Session.Context.cell_size/0, asked for with use Drafter.App, cell_size: true),
the grid is one stored image, fitted to the pane by the terminal and sent again only
when its tiles or the atlas change, and each mover is a placement of a sprite stored by
Cauldron2D.Drafter.Sprites.transmit/2 — a few dozen bytes per mover per frame, no
pixels. The surface stores the sprites itself, once per atlas and cell size, resampled
to the two cells by one row a tile occupies, so the terminal draws a sprite pixel for
pixel wherever it is placed. Elsewhere the movers are composited into one image per
frame.
The isometric and oblique panes are placed the same way while no volume is up: the pane is stored once at a quarter again its size, and a frame that only moved the camera shows that same image through a shifted source rectangle. Everything that moves is a patch — a small picture of the pane's own pixels around each mover, composed the way the whole frame is, so a mover behind a pillar is behind it in the patch too. Patches that touch are sent as one, and a frame whose moving parts cover too much of the pane is composited whole instead.
The protocol is protocol/1's; whether the terminal places is
FrenchCurve.Capability.placements?/1 asked of the session's environment, so an iTerm2
drawing with kitty graphics, which stores but never places, is composited every frame.
Summary
Functions
The pane cell a world point is drawn at, as world_at/3 reads one back.
The tag this widget answers to in a component tree: :cauldron_surface.
The mode the surface actually draws in: a cell set, :braille, a pixel protocol, or
:text where there is no picture to draw.
The surface as a picture protocol draws it: zooming in is fewer pixels for the same cells, and zooming out, where the projection can draw its own world smaller, is the same pixels with a smaller world in them.
The zoom a projection is actually drawn at in a picture height pixels tall.
Callback implementation for Drafter.Widget.handle_event/2.
Produce the frame as terminal-graphics bytes, or nil when there is nothing to draw.
Whether image/3 draws anything: a pixel protocol is in use and the view is set.
Build the widget's state from its props.
Record the pane's rect, and report it when it differs from the one already held.
The picture a projection is drawn into for rect, in the mode the surface is in.
The projections a surface draws the atlas world in: :top, :oblique, :iso, :mode7
and :first_person. A surface given a :picture draws that instead, as :picture.
The pixel protocol this surface draws with, or nil for glyphs.
Callback implementation for Drafter.Widget.unmount/1.
Fold re-rendered props into the widget state.
The world point under pane cell {column, row}, on the ground, for the view the
surface draws; nil in first person, where a cell is not a place. A :picture surface
gives the point of its picture there instead — the cell's centre for whole cells, the
point itself for fractional ones (a pixel-precise pointer) — as fractions of the
picture's width and height ({0.0, 0.0} the top-left corner); with a :frame it gives
the point of the frame Cauldron2D.Fit.letterbox/3 places in the picture, nil on
the bars around it.
The zoom levels a surface draws exactly, largest first: in :text mode and the pixel
modes (:kitty, :iterm2, :sixel, or nil for whichever is detected) 4 down to a
sixteenth, kept to the divisors of the atlas's tile size zoomed out; in :braille mode
those where a tile is a whole number of dots, from the tile's size in dots down to one.
The zoom levels the surface draws projection at exactly, largest first; see zoom_levels/2.
Types
@type t() :: %Cauldron2D.Drafter.Surface{ atlas: term(), cell: term(), compress: term(), density: term(), eye: term(), facing: term(), facings: term(), focus: term(), frame: term(), heading: term(), labels: term(), layers: term(), mode: term(), movers: term(), native: term(), native_scale: term(), on_geometry: term(), picture: term(), picture_format: term(), pitch: term(), placements: term(), projection: term(), rect: term(), swap: term(), tilt: term(), volumes: term(), widget_id: term(), zoom: term() }
Functions
The pane cell a world point is drawn at, as world_at/3 reads one back.
{column, row} relative to the pane's top-left corner, fractional, so a caller can see
where in the cell the point falls. nil in first person, and for a point the
projection cannot show. Round-trips with world_at/3.
@spec component_tag() :: atom()
The tag this widget answers to in a component tree: :cauldron_surface.
The mode the surface actually draws in: a cell set, :braille, a pixel protocol, or
:text where there is no picture to draw.
The surface as a picture protocol draws it: zooming in is fewer pixels for the same cells, and zooming out, where the projection can draw its own world smaller, is the same pixels with a smaller world in them.
A terminal that scales pictures to cells has no other way to zoom in than to hand it a
smaller picture. Zooming out that way costs four times the pixels a step, which is a
picture nobody can transmit thirty times a second; a projection that takes a :shrink
draws more world into the picture it already had instead.
@spec fitting_zoom(t(), pos_integer()) :: number()
The zoom a projection is actually drawn at in a picture height pixels tall.
The zoom asked for, rounded to a level the projection draws exactly, then pulled back until the tallest art stands in the half of the picture above its feet with a tile to spare, so a figure at the middle of the picture has room over its head. In the cell and braille modes a picture is only a few dozen pixels tall, so the closest zoom would otherwise show a figure's boots and nothing above them.
Callback implementation for Drafter.Widget.handle_event/2.
Produce the frame as terminal-graphics bytes, or nil when there is nothing to draw.
Returns nil when Cauldron2D.Renderer finds no pixel protocol, when :focus or :cell
is unset, or when composing the frame raises.
A frame that raises is logged with its stacktrace before nil is returned, so a missing
atlas or a bad mover shows up as an error rather than as a pane that is silently blank.
Frames are drawn many times a second, so the same message is logged once per widget process
rather than on every frame.
Otherwise returns {paint, clear, region}:
paint— the bytes that draw this frame, which already begin withclearclear— the bytes that remove this frame, empty for protocols that leave no stored image behindregion—%{dx: 0, dy: 0, cols: cols, rows: rows, place: place}covering the pane.placere-asserts an already-transmitted image's placement, and isnilfor protocols whose bytes are the pixels themselves
id identifies the image to the terminal across frames, so successive calls for the same
widget must pass the same id.
FrenchCurve.frame/3 builds the bytes, which matters because not every terminal detected as
:kitty keeps images under an id — iTerm does not, and a surface animated as though it did
would never move.
Whether image/3 draws anything: a pixel protocol is in use and the view is set.
Build the widget's state from its props.
Props may be a map or a keyword list. Every key falls back to its documented default when
absent, so compress: false means false rather than the default.
Record the pane's rect, and report it when it differs from the one already held.
With :on_geometry set and a drafter application running, the application is sent
%{rect: rect, size: {columns, rows}}, where size is the viewport in tiles. Those two,
with Cauldron2D.Camera.origin/2 and Cauldron2D.Camera.tile_at/2, are what maps a screen
position back to a world tile.
Returns the state carrying the new rect.
The picture a projection is drawn into for rect, in the mode the surface is in.
{{width, height}, {across, down}, aspect}: the picture's size in pixels, how many of
those pixels a terminal cell and a terminal row cover, and the pixel aspect the
projection is given. nil in the plain glyph mode, which draws the grid from above
whatever the projection is.
@spec projections() :: [atom()]
The projections a surface draws the atlas world in: :top, :oblique, :iso, :mode7
and :first_person. A surface given a :picture draws that instead, as :picture.
@spec protocol(t()) :: :kitty | :iterm2 | :sixel | nil
The pixel protocol this surface draws with, or nil for glyphs.
A forced :mode naming a protocol or a glyph rendering wins; then CAULDRON_MODE and
the application configuration, through Cauldron2D.Renderer.resolved/1; and where
those leave it to the terminal, Drafter.Widget.Chart.Pixel.terminal/0: what the
session's terminal answered the startup probe, or its environment when it was not
probed, with iTerm2 always judged by its environment.
Callback implementation for Drafter.Widget.unmount/1.
Fold re-rendered props into the widget state.
Every option the widget takes can be changed by a re-render, not only the camera ones. A key
present in props is used even when its value is nil or false, so a mode that was forced
can be unforced and compress can be turned off. A key absent from props leaves that part
of the state as it was.
The world point under pane cell {column, row}, on the ground, for the view the
surface draws; nil in first person, where a cell is not a place. A :picture surface
gives the point of its picture there instead — the cell's centre for whole cells, the
point itself for fractional ones (a pixel-precise pointer) — as fractions of the
picture's width and height ({0.0, 0.0} the top-left corner); with a :frame it gives
the point of the frame Cauldron2D.Fit.letterbox/3 places in the picture, nil on
the bars around it.
@spec zoom_levels(Cauldron2D.Atlas.name(), atom() | nil) :: [number()]
The zoom levels a surface draws exactly, largest first: in :text mode and the pixel
modes (:kitty, :iterm2, :sixel, or nil for whichever is detected) 4 down to a
sixteenth, kept to the divisors of the atlas's tile size zoomed out; in :braille mode
those where a tile is a whole number of dots, from the tile's size in dots down to one.
Those are the top-down projection's, and zoom_levels/3 gives another projection's:
isometric, 2 down to a quarter; oblique, 2 down to a half; first person and mode 7,
1 alone. The isometric view draws its own world smaller to zoom out, so its steps
out cost nothing; see fitted/1.
@spec zoom_levels(Cauldron2D.Atlas.name(), atom() | nil, atom()) :: [number()]
The zoom levels the surface draws projection at exactly, largest first; see zoom_levels/2.