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:

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.

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:

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:

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:

  1. the :picture mount prop
  2. config :cauldron_2d_drafter, picture: "native:2,swap"
  3. the CAULDRON_PICTURE environment variable
  4. the :picture key of the game's look/0
  5. Cauldron2D.Drafter.Client.Picture.default/0, which is native: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.

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

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