Cauldron2D.Drafter
This package puts a cauldron_2d game in a terminal,
through drafter. It also serves the game over ssh. It gives
you two things:
- A ready-made client.
Cauldron2D.Drafter.Clientis a whole drafter application. It runs anyCauldron2D.Client.Gamemodule through a title, a lobby, settings, the world, a summary and a guide. - Parts for your own application. Use these when your game has a different shape.
Cauldron2D.Drafter.Surfaceis thecauldron_surfacewidget. It draws a viewport onto a grid and its movers. It uses pixels where the terminal has them and glyphs elsewhere.Cauldron2D.Drafter.Spritessends an atlas to a kitty terminal once, so later frames only place the sprites.Cauldron2D.Drafter.Chatkeeps rooms of messages and sends each to its members.
The same Cauldron2D.Client.Game module also runs in a browser through
cauldron_2d_net and in a window through
cauldron_2d_wx.
Installation
def deps do
[
{:cauldron_2d_drafter, "~> 0.3"},
{:tuning_fork_speaker, "~> 0.1"}
]
end
This brings in cauldron_2d, drafter ~> 0.5 and the gamepad package for game
controllers. Add tuning_fork_speaker to hear sound on this machine. Add
cauldron_gpu as well if your game draws on the
graphics card. It is an optional dependency here.
Running a game in the terminal
Write a client module for your game. This one shows the Walk.Game from the
cauldron_2d README. It builds a two-tile atlas in code, lists one world of the player's
own, and starts straight in it:
defmodule Walk.Client do
@behaviour Cauldron2D.Client.Game
alias Cauldron2D.Atlas
alias FrenchCurve.Raster
@impl true
def title, do: %{name: "Walk", tagline: "left and right"}
@impl true
def atlas do
Atlas.new(:walk, tile: 8)
|> Atlas.put(:floor, Raster.new(8, 8, background: {40, 44, 60, 255}), glyph: ". ", color: {90, 96, 120})
|> Atlas.put(:walker, Raster.new(8, 8, background: {240, 200, 80, 255}), glyph: "@ ", color: {240, 200, 80})
|> Atlas.install()
end
@impl true
def actions, do: [:left, :right]
@impl true
def keymaps, do: [arrows: %{left: [:left], right: [:right]}]
@impl true
def toggles, do: []
@impl true
def worlds(_props),
do: [%{id: :field, name: "A field of your own", world: {Walk.Game, [hz: 30]}}]
@impl true
def scene(%{me: me, width: width}) do
%{
focus: {width / 2, 0.5},
bounds: {width, 1},
cell: fn _tile -> {:floor, [], nil} end,
movers: [{:walker, {me.x, 0.0}}]
}
end
@impl true
def hud(%{me: me}), do: [["x ", {Float.to_string(Float.round(me.x, 1)), {240, 200, 80}}]]
@impl true
def listener(%{me: me}), do: {me.x, 0.0}
@impl true
def sounds, do: fn _event -> nil end
@impl true
def music, do: []
@impl true
def cue(_view, _screen), do: nil
@impl true
def start, do: {:play, :first}
end
Then load and register the surface widget, and run the client with your module in its props. A mix task is a good place for this:
Code.ensure_loaded!(Cauldron2D.Drafter.Surface)
Drafter.Widget.Registry.register(Cauldron2D.Drafter.Surface)
Drafter.run(Cauldron2D.Drafter.Client, props: %{game: Walk.Client, username: "alice"})
Register the widget only after loading the module. Registration reads the module's
component_tag/0. An unloaded module registers nothing, and its tag then draws an empty
pane. The same holds for Drafter.run/2's :widget_libraries option.
The examples in the repository's examples directory each have a mix task written this
way: mix cistern, mix beast, mix town, mix parade.
The ready-made client
The client holds the player's Cauldron2D.Input, their Cauldron2D.Audio and their
connection to the Cauldron2D.World. The game supplies every name and element the
screens show. The client supplies the flow between screens and the keys that drive it.
- Flow. The game's
start/0, or the:startmount prop, chooses where the client starts.:lobbyshows the title, then the lobby.{:lobby, title: false}starts at the lobby.{:play, id}goes straight into a world. A function of the client's state runs a flow of the game's own, usingenter/3,to_lobby/1,to_title/1andto_guide/1. - Lobby. It lists the worlds from the game's
worlds/1with their players, and redraws as players come and go. It offers teams where a world has them, and a chat unless the game'schat?/0returnsfalse. - Keys.
Escgoes back,qleaves the world for the lobby,?shows the key hints and^Qquits.=,-and0zoom.vcycles the projections the game'slook/0lists: top-down, oblique, isometric, mode 7 and first person.[and]turn the view. The game can replace or drop any of these withclient_keys/0.Cauldron2D.Drafter.Clientdocuments every key. - Controllers. Each connected controller plays one player. The d-pad and left stick
hold the game's directions. In a game's menu, the d-pad arrives as arrow keys,
AasEnter, andBandStartasEsc, each sent as{:key, key, [:pad]}. - Guide. A game with
guide/0gets a guide screen onhfrom the title and lobby. - Settings. The player sets the control preset and bindings, pointer steering,
effects and music levels, the display and the frame rate. The client saves them under
$XDG_CONFIG_HOME/<game>/<player>.settings, and in the player's account when there is one. - Sound. Pass
mute: truein the props, setconfig :cauldron_2d_drafter, mute: true, or setCAULDRON_MUTE=1to play no sound.
A game with screens of its own can run the client inside its own Drafter.App. Start it
with start: {:lobby, title: false} and hand it events through handle_event/2,
handle_event/3, on_message/2 and on_ready/1. The Embedding section of
Cauldron2D.Drafter.Client lists the use Drafter.App options your app needs.
Serving over ssh and telnet
Cauldron2D.Drafter.Server.start/1 serves the game over ssh. It registers the surface
widget, keeps player accounts, gives each account a sound port, starts the game's web
endpoint if you give one, and announces the server on the local network:
{:ok, server} =
Cauldron2D.Drafter.Server.start(
game: Walk.Client,
port: 2222,
web: {:my_game, MyGame.Web.Endpoint, 2280}
)
Cauldron2D.Drafter.Server.stop(server)
New players register by logging in as new@host. Each player's sound goes as raw PCM
through an ssh -R <port>:127.0.0.1:4713 tunnel to a player program on their machine.
Register the worlds with Cauldron2D.Worlds before or after the server starts.
To serve without accounts, call drafter's server directly. This serves the client over
telnet. Pass mute: true, because a telnet session has no sound tunnel:
Code.ensure_loaded!(Cauldron2D.Drafter.Surface)
Drafter.Widget.Registry.register(Cauldron2D.Drafter.Surface)
Drafter.Server.start_telnet(Cauldron2D.Drafter.Client,
port: 2323,
mount_props: %{game: Walk.Client, mute: true}
)
Drafter.Server.start_ssh/2 takes the same mount_props. See drafter's remote TUI guide
for its other options.
The surface widget
{:cauldron_surface,
[
id: :map,
atlas: :my_game,
focus: {x, y},
cell: &MyGame.cell/1,
movers: [{:ball, {7.42, 3.15}}],
labels: [{"alice", {7.0, 2.0}, {255, 255, 0}}],
flex: 1
]}
An art with frames is drawn at the frame showing on the clock. A label is text in
Linocut.Font over the world, or characters over the glyphs in text mode. The widget also
takes :zoom, :mode, :projection, :layers, :volumes, :picture and more.
Cauldron2D.Drafter.Surface documents each option.
The widget draws the same view two ways:
- Pixels.
image/3composites the viewport and hands drafter one image as a kitty, iTerm2 or sixel escape sequence.render/2paints blank cells under it. - Text.
render/2draws two-cell glyphs in truecolour, from the same cells. Set:modeto:braille,:half,:quad,:sextantor:octantto draw block or braille characters instead.
Kitty transmits are zlib-compressed. Pass compress: false for a terminal whose kitty
support is partial.
On a kitty terminal whose cell size the session knows, the grid is one stored image. It
is sent again only when its tiles change. Each mover is a placement of a sprite that
Cauldron2D.Drafter.Sprites.transmit/2 stored once, at a sub-cell pixel offset.
Elsewhere, the movers are composited into one image per frame.
The picture
A scene with a :picture function draws its own frame. On a terminal that keeps images
(kitty, Ghostty, WezTerm, iTerm2), Cauldron2D.Drafter.Client.Picture decides what size
to draw it at and how to send it. Give the options as a list or a comma-separated
string:
nativedraws the frame at the game's own size times the largest whole number that fits, and the terminal scales it to the pane. The game gives its size as the scene's or the look's:native.native:Ndraws it at the game's own size timesN.size:WxHsets the game's own size, over what the game declares.pngsends each frame as a PNG. A frame with at most 256 colours goes as a palette PNG. It takes more CPU to encode, so use it over ssh rather than locally.swapstores each frame before letting the last one go, so the pane never blanks.gpuandwindowdraw on the graphics card. See below.noneturns all of these off.
Without native, the frame is drawn at the pane's cells times the surface's density.
The client reads the options from the first of these that is set:
- the
:picturemount prop config :cauldron_2d_drafter, picture: "native:2,swap"- the
CAULDRON_PICTUREenvironment variable - the
:picturekey of the game'slook/0 Cauldron2D.Drafter.Client.Picture.default/0, which isnative:2,swap
The cost of a frame grows with its pixel count. Give a game whose frames are expensive to
draw a default of native:1 in its look/0. Plain native on a large window can ask for
many times the game's own size.
A mix task can take a --picture flag and pass it through
Cauldron2D.Drafter.Client.Picture.parse/1.
The graphics card
A scene can describe itself for the graphics card with a :gpu function as well as its
:picture (see Cauldron2D.Client.Game and
cauldron_gpu). Such a game is drawn on the card by
default. You do not need a flag.
- When there is a card, the client draws the picture offscreen at the pane's pixels and shows it in the pane. This works on every terminal that shows pictures.
- When there is no card, or the renderer cannot open, the client draws the game's own
:picture. Nothing changes for the player. - A game that never gives a
:gpunever starts a renderer.
Each player can change this in the settings screen's Drawing row, and a game can offer it in its own menus. The setting is saved with the player's other settings. It has four values: automatic (the default), software only, the card in the terminal, and the card in a window of its own. In a window, keys pressed in the window reach the client as terminal keys, with key releases, and the pane stays empty while the window is open.
A run can override the setting with the picture options software, gpu or window:
mix my_game --picture native:2,swap,window
CAULDRON_PICTURE=software mix my_game
A 2D game whose picture is a Cauldron2D.DrawList can give the same draw list to the
card with Cauldron2D.Drafter.Client.Sprites. It turns the items into
CauldronGPU.Sprites for a CauldronGPU.SpriteLayer and uploads each picture the first
time it is drawn. Sprites land on the same pixels as the software picture. NidhOX
(Nidhox.Gpu) draws this way.
def scene(view) do
%{
picture: fn size, _aspect -> MyGame.picture(view, size) end,
gpu: fn target, cache, size, _aspect -> MyGame.Gpu.draw(view, target, cache, size) end
}
end
def draw(view, target, cache, {width, height}) do
cache = cache || Sprites.new()
{sprites, cache} = Sprites.sprites(cache, target, view.items, &{&1, MyGame.raster(&1)})
layer = %CauldronGPU.SpriteLayer{clear: {0.0, 0.0, 0.0, 1.0}, view: {0, 0, 320, 200}, sprites: sprites}
{[{:layers, [layer]}], cache}
end
The renderer runs in its own OS process. Cauldron2D.Drafter.Client.Stage starts it
through CauldronGPU.Host and restarts it with its scene if it crashes. If you asked for
the card by name and it cannot be used, or the window is closed, the client draws the
game's own picture and shows a short note saying why.
The setup page
Cauldron2D.Drafter.Launcher is a page shown before a game starts. The player sets the
things a game needs before it can run: where its data is, the skill, how it is drawn.
Play saves the choices, and later runs start straight away.
fields = [
%{key: :dir, label: "Game folder", kind: {:path, &MyGame.Assets.check/1},
discover: &MyGame.Assets.discover/0, hint: "the folder holding the game's files"},
%{key: :skill, label: "Skill", kind: {:choice, [{1, "easy"}, {2, "normal"}]}, default: 2},
Cauldron2D.Drafter.Launcher.drawing_field()
]
case Cauldron2D.Drafter.Launcher.choose(MyGame.Client, fields, overrides: flags, player: name) do
{:ok, values} -> start_the_game(values)
:quit -> :ok
end
- The page opens on the first run, when a saved path no longer passes its check, or when
you pass
launcher: true(a game's--launcherflag). - A path field's
:discoverfunction looks in the usual places when nothing is saved.Launcher.steam_folders/1lists where Steam keeps a game on macOS (also in CrossOver), Linux and Windows. - Values given as
:overrides(from flags) win for that run and are not saved. - The choices are saved in
$XDG_CONFIG_HOME/<game>/launcher.config. The Drawing field is also written into the player's settings, so the page and the game's options change the same setting.
On the page, ↑ and ↓ move, ← and → change a choice, Enter edits a path or presses a button, and Escape quits.
Tools
mix cauldron.input measures drafter's input path from a DRAFTER_TRACE log. With
CAULDRON_TRACE set, the client writes its keys, mouse events, input and frames to a
trace that mix cauldron.report reads.
License
MIT