Cauldron2D.Drafter.Surface (Cauldron2D.Drafter v0.2.2)

Copy Markdown View Source

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 with Cauldron2D.Atlas.install/1
  • :focus, :cell — passed to Cauldron2D.Camera.view/1
  • :movers — [{art, {x, y}}] or [{art, {x, y}, heading}] in world tile coordinates, which may be fractional, heading in 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, as Cauldron2D.Surface stands 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, :back or :side and 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, as Cauldron2D.Projection.Volume describes 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, as Cauldron2D.Surface.compose/4 takes them: names, numbers, notes drawn over everything. Default []. In pixel mode a label is drawn in Linocut.Font at 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: 2 twice as large, 0.5 half, so a pane shows half or twice the tiles. Default 1. Levels a surface draws exactly are zoom_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 — a Cauldron2D.Renderer mode 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 a FrenchCurve.Backend.Cells glyph set, :half, :quad, :sextant or :octant: the frame composed at twice the set's pixels per cell across a tile, drawn as block characters in two colours per cell. Default nil
  • :compress — zlib the kitty transmit. Default true
  • :placements — :auto (default), true or false. Whether the pane is drawn from images the terminal stores between frames rather than one image composited every frame; see below
  • :projection — :top (default), :iso or :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; aspect is 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 :projection is :picture; :focus, :cell and :movers are 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. Default nil
  • :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 — true stores 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. Default false
  • :facing — which step of a turn the isometric view is at, 0 to facings - 1. A fraction is allowed and turns the view by part of a step. Default 0
  • :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 takes 1 or 4. For the isometric view, at least 4. At 4 the 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. Default 4
  • :tilt — the isometric diamond's height as a fraction of the tile, 0.5..1.5. Default 1.0
  • :heading — radians, where the first-person eye looks, 0 along +x. Default 0.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 with Cauldron2D.Atlas.eye_height/2, so a taller figure sees further over the world. Default nil, the projection's own
  • :pitch — -1.0..1.0, how far the first-person horizon is moved. Default 0.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, and 4 in first person and mode 7
  • :on_geometry — an event name sent to the application whenever the pane's rect changes, see on_rect_change/2. Default nil
  • :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 passes image_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.

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

t()

@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

cell_at(state, rect, world)

@spec cell_at(t(), map(), {number(), number()}) :: {float(), float()} | nil

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.

component_tag()

@spec component_tag() :: atom()

The tag this widget answers to in a component tree: :cauldron_surface.

drawn_mode(state)

@spec drawn_mode(t()) :: atom()

The mode the surface actually draws in: a cell set, :braille, a pixel protocol, or :text where there is no picture to draw.

fitted(state)

@spec fitted(t()) :: t()

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.

fitting_zoom(state, height)

@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.

focused(state)

handle_event(event, state)

Callback implementation for Drafter.Widget.handle_event/2.

image(state, rect, id)

@spec image(t(), map(), term()) :: {iodata(), iodata(), map()} | nil

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 with clear
  • clear — the bytes that remove this frame, empty for protocols that leave no stored image behind
  • region — %{dx: 0, dy: 0, cols: cols, rows: rows, place: place} covering the pane. place re-asserts an already-transmitted image's placement, and is nil for 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.

image_active?(state)

@spec image_active?(t()) :: boolean()

Whether image/3 draws anything: a pixel protocol is in use and the view is set.

mount(props)

@spec mount(map() | keyword()) :: t()

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.

on_rect_change(rect, state)

@spec on_rect_change(map(), t()) :: t()

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.

picture(state, rect)

@spec picture(t(), map()) ::
  {{number(), number()}, {number(), number()}, number()} | nil

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.

preferred_height(args, opts)

projections()

@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.

protocol(surface)

@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.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@spec update(map() | keyword(), t()) :: t()

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.

update_props_from_mount(mount_props, existing_state, opts)

world_at(state, rect, cell)

@spec world_at(t(), map(), {number(), number()}) :: {float(), float()} | nil

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.

zoom_levels(atlas, mode)

@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.

zoom_levels(atlas, mode, projection)

@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.