Bunyip a game engine in Go GitHub

Example examples/gallery

Gallery

Gallery

This program is the catalogue of ui. A panel holds buttons, checkboxes, dropdowns, sliders, a text field, a progress bar, a tooltip and a scroll area; a draggable window holds tabs with radios, spinners, list boxes, a tree, a text area, rich labels with links, a colour picker, a table, a reorderable list and drag and drop; a menu bar opens menus; two modals sit over the lot. A dropdown switches between the built-in themes and a checkbox swaps in a texture skin the program generates.

The interface is immediate mode. Nothing here is a retained widget: every call inside u.Begin builds that widget for this frame, from Go values the game owns. A widget that edits something takes a pointer to it, and a widget that can be triggered returns whether it was. Containers take closures, so nesting is what scopes them and there is no end call to forget. The interface guide explains the model, identity and themes.

A third window is a small particle editor: sliders and two curve editors tune a particle.Emitter while it burns beside them, and Save writes it as the JSON asset.Emitter loads, so a game ships its effects as assets rather than as code.

Behind the widgets the program also draws with gfx: a dozen translucent squares moving on a Lissajous path, and three sizes of text from one distance-field atlas.

Run it:

go run ./examples/gallery -seconds 3 -shot out.png

The flags are -seconds N and -shot file.png, -beep to play a tone at start, -debug for the frame-timing overlay that F3 also toggles, -skin to start with the texture skin, -theme name for the starting theme, and -tab N for which tab of the window to open on.

Package and state

Every widget's value lives on the game. That is the shape of an immediate-mode interface: the checkbox does not remember whether it is checked, g.check does. The fields are split into the main panel's state and the second window's.

// Command gallery shows every UI widget, the built-in colour themes, a
// texture skin, scalable SDF text and an audio beep.
package main

import (
	"flag"
	"fmt"
	"image"
	"image/color"
	"math"
	"os"
	"strings"

	"golang.org/x/image/font/gofont/gobold"
	"golang.org/x/image/font/gofont/goregular"

	"github.com/matjam/bunyip/audio"
	"github.com/matjam/bunyip/engine"
	"github.com/matjam/bunyip/gfx"
	"github.com/matjam/bunyip/input"
	"github.com/matjam/bunyip/lin"
	"github.com/matjam/bunyip/ui"
)

type gallery struct {
	seconds  float64
	shot     string
	beep     bool
	skinned  bool
	theme    string
	tone     *audio.Sound
	startTab int

	font     *gfx.Font
	big      *gfx.Font
	ui       *ui.Context
	themeIdx int
	check    bool
	volume   float32
	name     string
	clicks   int
	quality  int
	skin     *ui.Skin
	skinTex  []*gfx.Texture
	useSkin  bool
	shotDone bool

	// The second window's state.
	bold     *gfx.Font
	win      ui.Rect
	tab      int
	radio    int
	lives    int
	players  int
	sel      int
	fog      bool
	notes    string
	linkHits int
	tint     gfx.Color
	confirm  bool
	about    bool
	order    []string
	drops    int
	lastDrop string

	// The particle editor's window and the texture its effects draw with.
	edit *editor
	soft *gfx.Texture
}

Init: fonts, theme, skin and a sound

Three fonts are loaded. NewFont rasterises at one size, which is what most interface text wants. NewSDFFont builds a distance-field atlas instead, which stays sharp at any size, so the three headings later are drawn from that one atlas with different Size values. AtlasSize: 1024 gives it room.

ui.ThemeNames lists the built-in palettes and ui.NamedTheme builds one around a font. ui.New takes the graphics context and a theme and returns the interface context, which is the value every widget call goes through.

g.ui.OnTextInputRect = ctx.SetTextInputRect reports where the focused text field is, so an input method's candidate window appears beside it.

The last three calls hand the gallery's own state to the debug console, which main turns on with Config.Console. Console.Float and Console.Bool bind a name to a pointer, so set gallery.volume 0.2 at the command line moves the slider and the Services panel shows both values live. Console.Register adds a command: theme with no argument lists the palettes and theme light switches to one, which is the same work the dropdown does. Binding a pointer rather than copying a value is what keeps the two in step in both directions; the widget writes through the same pointer the console reads.

Every console method is safe on a nil console, so these lines stay compiling and do nothing when Config.Console is off.

func (g *gallery) Init(ctx *engine.Context) error {
	var err error
	if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 16, gfx.FontOptions{}); err != nil {
		return err
	}
	if g.big, err = ctx.Gfx.NewSDFFont(goregular.TTF, 32, gfx.FontOptions{AtlasSize: 1024}); err != nil {
		return err
	}
	if g.bold, err = ctx.Gfx.NewFont(gobold.TTF, 16, gfx.FontOptions{}); err != nil {
		return err
	}
	g.volume, g.check = 0.65, true
	g.win = ui.Rect{X: 560, Y: 200, W: 320, H: 350}
	g.lives, g.players, g.sel = 3, 2, 1
	g.tint = gfx.RGB(255, 140, 40)
	g.order = []string{"Scout", "Archer", "Knight", "Mage"}
	g.tab = g.startTab
	g.notes = "Multi-line notes wrap here.\nSelect with Shift and the arrows, cut, copy, paste and undo."
	names := ui.ThemeNames()
	for i, n := range names {
		if n == g.theme {
			g.themeIdx = i
		}
	}
	theme, _ := ui.NamedTheme(names[g.themeIdx], g.font)
	g.ui = ui.New(ctx.Gfx, theme)
	g.ui.OnTextInputRect = ctx.SetTextInputRect
	if g.skin, g.skinTex, err = makeSkin(ctx.Gfx); err != nil {
		return err
	}
	g.useSkin = g.skinned
	g.applyTheme()
	if g.tone, err = ctx.Audio.NewSound(audio.Sine(440, 0.35, ctx.Audio.Rate())); err != nil {
		return err
	}
	if g.beep {
		ctx.Audio.Play(g.tone, audio.PlayOptions{Volume: 0.4})
	}
	if g.soft, err = softCircle(ctx.Gfx); err != nil {
		return err
	}
	g.edit = newEditor(g.soft, lin.V2(452, 580))
	// Two of the gallery's own values through the console: set them from
	// the command line, or watch them in the Services panel.
	ctx.Console.Float("gallery.volume", &g.volume, "the volume slider's value")
	ctx.Console.Bool("gallery.skin", &g.useSkin, "draw the widgets from the texture skin")
	ctx.Console.Register("theme", "theme <name>: switch the interface theme", func(args []string) (string, error) {
		if len(args) == 0 {
			return strings.Join(ui.ThemeNames(), " "), nil
		}
		for i, n := range ui.ThemeNames() {
			if n == args[0] {
				g.themeIdx = i
				g.applyTheme()
				return "theme " + n, nil
			}
		}
		return "", fmt.Errorf("no theme %q", args[0])
	})
	return nil
}

applyTheme rebuilds the theme whenever the dropdown or the skin checkbox changes. A theme is a value, so switching one is an assignment; a skin is a set of nine-slice textures hung on the theme, and the border width is zeroed because the skin draws its own edges.

// applyTheme rebuilds the theme from the chosen palette and skin.
func (g *gallery) applyTheme() {
	theme, _ := ui.NamedTheme(ui.ThemeNames()[g.themeIdx], g.font)
	if g.useSkin {
		theme.Skin = g.skin
		theme.BorderWidth = 0
	}
	g.ui.Theme = theme
}
func (g *gallery) Shutdown(ctx *engine.Context) {
	g.font.Destroy()
	g.big.Destroy()
	g.bold.Destroy()
	g.soft.Destroy()
	for _, t := range g.skinTex {
		t.Destroy()
	}
}

Update

The interface is built in Draw, so Update only handles quitting and the screenshot. g.ui.WantsKeyboard is why Escape does not quit while a text field has focus.

The first thing it does is give way to the console. While the drop-down is open, returning early prevents gameplay shortcuts in Update from also responding to console input. Draw still builds the gallery UI; this early return alone does not prevent its text fields from reading the same input. Games with both interfaces should gate their own UI input while the console is open as well.

func (g *gallery) Update(ctx *engine.Context) error {
	if ctx.Console.Open() {
		return nil // the console has the keyboard
	}
	if ctx.Input.KeyPressed(input.KeyEscape) && !g.ui.WantsKeyboard() || (g.seconds > 0 && ctx.Time >= g.seconds) {
		ctx.Quit()
	}
	if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
		ctx.Screenshot(g.shot)
		g.shotDone = true
	}
	g.edit.update(ctx.Delta)
	return nil
}

Draw: the background and the scalable text

The squares are drawn before the interface, so they end up behind it; within a layer the order is call order. gfx.RGBA takes an alpha byte for the translucency.

DrawTextBlock with gfx.TextOptions{Size: ...} draws the distance-field font at any size, and Angle slants it, both from the same atlas. A regular font drawn at a size other than the one it was rasterised at would be blurred instead.

func (g *gallery) Draw(ctx *engine.Context) error {
	t := float32(ctx.Time)
	for i := range 12 {
		x := ctx.Width/2 + 200*float32(math.Cos(float64(t*0.4+float32(i))))
		y := ctx.Height/2 + 150*float32(math.Sin(float64(t*0.6+float32(i)*1.3)))
		ctx.Gfx.FillRect(x-30, y-30, 60, 60, gfx.RGBA(uint8(80+i*12), 90, uint8(200-i*10), 120))
	}
	// Scalable text: one SDF atlas, drawn at three sizes and a slant.
	ctx.Gfx.DrawTextBlock(g.big, "Bunyip", 380, 40, gfx.TextOptions{Size: 72 + 8*float32(math.Sin(float64(t))), Angle: -0.08}, gfx.RGB(255, 220, 120))
	ctx.Gfx.DrawTextBlock(g.big, "scalable text from one atlas", 384, 130, gfx.TextOptions{Size: 22}, gfx.RGB(200, 200, 215))
	ctx.Gfx.DrawTextBlock(g.big, "tiny", 384, 160, gfx.TextOptions{Size: 11}, gfx.RGB(150, 150, 170))
	// The effect the particle editor is tuning, under every window.
	g.edit.preview(ctx.Gfx)

Draw: the frame, the menu bar and the window

u.Theme.BoldFont and u.Clipboard are set before the frame begins: the bold font is what rich labels use for [b], and the clipboard is an interface the context satisfies, which is what lets the text fields cut and paste.

Everything else happens inside u.Begin(ctx.Input, func() { ... }), which takes this frame's input. u.MenuBar and u.Menu take closures and u.MenuItem returns true on the frame it is chosen. u.Window takes a pointer to a ui.Rect, which is how dragging the window moves it: the widget writes the new position back into the game's own value.

u.Tabs takes the labels and a pointer to the selected index, and the switch that follows builds only the widgets of the open tab. That is worth noticing: a widget that is not called does not exist this frame, so there is nothing to hide or show.

The tabs run through the rest of the catalogue. u.Radio writes an index into one variable, u.IntSlider and u.Spinner edit integers, u.ListBox and u.TreeOpen hold a selection and a fold, u.TextArea edits a multi-line string, u.RichLabel returns the name of a link that was clicked, u.ColorPicker edits a gfx.Color, and u.Table calls back for each cell. u.ReorderableList returns the indices of a move, which ui.Move applies to the slice, and u.DragSource with u.DropTarget carries an arbitrary value from one to the other.

	u := g.ui
	u.Theme.BoldFont = g.bold
	u.Clipboard = ctx
	u.Begin(ctx.Input, func() {
		u.MenuBar(ui.Rect{X: 0, Y: 0, W: ctx.Width, H: 22}, func() {
			u.Menu("File", func() {
				if u.MenuItem("Reset clicks") {
					g.clicks = 0
				}
				if u.MenuItem("Quit") {
					g.confirm = true
				}
			})
			u.Menu("Help", func() {
				if u.MenuItem("About") {
					g.about = true
				}
			})
		})
		u.Window("More widgets (drag me)", &g.win, func() {
			u.Tabs([]string{"Widgets", "Text", "Colour", "Drag"}, &g.tab)
			switch g.tab {
			case 0:
				u.Row(3, func() {
					for i, d := range []string{"Easy", "Normal", "Hard"} {
						u.Radio(d, &g.radio, i)
					}
				})
				u.IntSlider("Lives", &g.lives, 1, 9)
				u.Spinner("Players", &g.players, 1, 8, 1)
				u.ListBox("Maps", 56, []string{"Archipelago", "Pangaea", "Fractal", "Highlands"}, &g.sel)
				u.TreeOpen("Options", func() {
					u.Checkbox("Fog of war", &g.fog)
				})
			case 1:
				u.TextArea("Notes", &g.notes, 120)
				if link := u.RichLabel("[b]Rich[/b] labels mix [#ff8a5c]colour[/#], [u]underlines[/u] and a [link=docs]link[/link] you can click."); link != "" {
					g.linkHits++
				}
				u.Label(fmt.Sprintf("Link clicks: %d", g.linkHits))
			case 2:
				u.ColorPicker("Tint", &g.tint)
				u.Table([]string{"Channel", "Value"}, []float32{1, 1}, 3, func(row, col int) {
					names, vals := []string{"Red", "Green", "Blue"}, []float32{g.tint.R, g.tint.G, g.tint.B}
					if col == 0 {
						u.Cell(names[row])
					} else {
						u.Cell(fmt.Sprintf("%.2f", vals[row]))
					}
				})
			case 3:
				// Rows drag to a new place (or Ctrl and an arrow move the
				// focused one); the buttons drag onto the label below.
				if from, to, ok := u.ReorderableList("Turn order", g.order, 100); ok {
					ui.Move(g.order, from, to)
				}
				u.Row(3, func() {
					for _, item := range []string{"Sword", "Shield", "Potion"} {
						u.DragSource(item, item, func() { u.Button(item) })
					}
				})
				u.Label(fmt.Sprintf("Drop here: %d (%s)", g.drops, g.lastDrop))
				if p, ok := u.DropTarget("chest", nil); ok {
					g.drops++
					g.lastDrop = p.(string)
				}
			}
		})

Draw: modals and the main panel

u.Modal takes a pointer to the bool that says whether it is open, so a menu item opens it by setting the bool and a button closes it by clearing it. Modals and menus are drawn deferred at the end of the frame, which is what puts them over the widgets that were built before them.

u.Columns and u.Row lay the next widgets out side by side. u.Dropdown and u.Checkbox return whether they changed, which is what triggers the theme rebuild. u.Tooltip attaches to the widget just built, u.Separator draws a rule, and u.ScrollArea takes a rectangle, the height of its content and a closure that builds it.

		u.Modal("Quit?", ui.Rect{X: ctx.Width/2 - 140, Y: ctx.Height/2 - 60, W: 280, H: 110}, &g.confirm, func() {
			u.Label("Leave the gallery?")
			u.Row(2, func() {
				if u.Button("Quit") {
					ctx.Quit()
				}
				if u.Button("Stay") {
					g.confirm = false
				}
			})
		})
		u.Modal("About", ui.Rect{X: ctx.Width/2 - 160, Y: ctx.Height/2 - 70, W: 320, H: 130}, &g.about, func() {
			u.Label("Bunyip's immediate-mode interface: every widget here is rebuilt each frame.")
			if u.Button("Close") {
				g.about = false
			}
		})
		u.Panel("Bunyip UI gallery", ui.Rect{X: 24, Y: 24, W: 320, H: 520}, func() {
			u.Label("Widgets rebuild every frame from Theme values; long labels wrap to the panel.")
			u.Columns([]float32{2, 1}, func() {
				if u.Dropdown("Theme", &g.themeIdx, ui.ThemeNames()) {
					g.applyTheme()
				}
				if u.Checkbox("Skin", &g.useSkin) {
					g.applyTheme()
				}
			})
			u.Row(2, func() {
				if u.Button(fmt.Sprintf("Clicked %d times", g.clicks)) {
					g.clicks++
				}
				u.Tooltip("Tab and Shift-Tab move focus; Enter activates.")
				if u.Button("Beep") {
					ctx.Audio.Play(g.tone, audio.PlayOptions{Volume: g.volume, Pan: 0})
				}
				u.Tooltip("Plays a 440 Hz sine through the mixer.")
			})
			u.Checkbox("Show hints", &g.check)
			u.Dropdown("Quality", &g.quality, []string{"Low", "Medium", "High", "Ultra"})
			u.Separator()
			u.Slider("Volume", &g.volume, 0, 1)
			u.TextField("Type a name", &g.name)
			u.Progress(fmt.Sprintf("Loading %d%%", int(50+50*math.Sin(float64(t)))), 0.75+0.25*float32(math.Sin(float64(t))))
			if g.check {
				u.Label("Escape quits; click a field and type.")
			}
			u.ScrollArea("log", ui.Rect{X: 36, Y: 420, W: 296, H: 110}, 20*28, func() {
				for i := range 20 {
					u.Label(fmt.Sprintf("Scrollable line %d", i+1))
				}
			})
		})
		g.edit.draw(u)
	})
	// The engine draws the console above the gallery after this returns:
	// ` opens it and F4 opens the debug panels.
	return nil
}

Building a skin

makeSkin generates the textures a skin needs, standing in for the art a game would load. A ui.Slice is a nine-slice: a texture and four border widths, so the middle stretches and the corners do not. NoMipmaps: true keeps a skin texture crisp, since it is never seen at a distance.

Each field of ui.Skin is the image for one part in one state, and any left nil falls back to the theme's flat drawing.

// makeSkin draws a small set of rounded, bevelled textures and wires
// them into a Skin, standing in for the art a game would load.
func makeSkin(g *gfx.Graphics) (*ui.Skin, []*gfx.Texture, error) {
	var texs []*gfx.Texture
	slice := func(img image.Image, border float32) (*ui.Slice, error) {
		tex, err := g.NewTexture(img, gfx.TextureOptions{Linear: true, NoMipmaps: true})
		if err != nil {
			return nil, err
		}
		texs = append(texs, tex)
		return &ui.Slice{Tex: tex, Left: border, Top: border, Right: border, Bottom: border}, nil
	}
	c := func(r, g, b, a uint8) color.NRGBA { return color.NRGBA{r, g, b, a} }
	var err error
	sk := &ui.Skin{}
	set := func(dst **ui.Slice, img image.Image, border float32) {
		if err == nil {
			*dst, err = slice(img, border)
		}
	}
	set(&sk.Panel, rounded(48, 12, 3, c(36, 30, 52, 235), c(140, 120, 190, 255), c(60, 50, 85, 235)), 14)
	set(&sk.Button, rounded(32, 9, 2, c(96, 72, 150, 255), c(170, 150, 220, 255), c(70, 52, 112, 255)), 11)
	set(&sk.ButtonHover, rounded(32, 9, 2, c(120, 92, 180, 255), c(200, 180, 240, 255), c(90, 70, 140, 255)), 11)
	set(&sk.ButtonActive, rounded(32, 9, 2, c(60, 44, 100, 255), c(120, 100, 170, 255), c(50, 36, 80, 255)), 11)
	set(&sk.Field, rounded(32, 7, 2, c(22, 18, 34, 255), c(110, 95, 150, 255), c(22, 18, 34, 255)), 9)
	set(&sk.FieldFocus, rounded(32, 7, 2, c(22, 18, 34, 255), c(250, 200, 90, 255), c(22, 18, 34, 255)), 9)
	set(&sk.Check, rounded(24, 6, 2, c(22, 18, 34, 255), c(110, 95, 150, 255), c(22, 18, 34, 255)), 8)
	set(&sk.CheckOn, rounded(24, 6, 2, c(250, 200, 90, 255), c(255, 230, 150, 255), c(220, 160, 60, 255)), 8)
	set(&sk.Track, rounded(16, 5, 1, c(30, 24, 44, 255), c(80, 66, 110, 255), c(30, 24, 44, 255)), 6)
	set(&sk.Fill, rounded(16, 5, 1, c(250, 200, 90, 255), c(255, 230, 150, 255), c(220, 160, 60, 255)), 6)
	set(&sk.Knob, rounded(24, 11, 2, c(240, 235, 250, 255), c(255, 255, 255, 255), c(180, 170, 210, 255)), 11)
	set(&sk.Thumb, rounded(16, 6, 1, c(150, 130, 200, 255), c(200, 180, 240, 255), c(120, 100, 170, 255)), 7)
	if err != nil {
		for _, t := range texs {
			t.Destroy()
		}
		return nil, nil, err
	}
	return sk, texs, nil
}

rounded draws one of those images: a rounded square from a signed distance function, filled with a vertical gradient, ringed with an edge colour and antialiased over the last half pixel. Returning an image.NRGBA matters, because the alpha is unpremultiplied and NewTexture premultiplies it in linear light on the way to the GPU.

// rounded draws a size×size rounded square: fill graded from top to
// bottom colour with an edge ring of the given width.
func rounded(size int, radius, edge float64, top, ring, bottom color.NRGBA) *image.NRGBA {
	img := image.NewNRGBA(image.Rect(0, 0, size, size))
	mix := func(a, b color.NRGBA, t float64) color.NRGBA {
		l := func(x, y uint8) uint8 { return uint8(float64(x)*(1-t) + float64(y)*t) }
		return color.NRGBA{l(a.R, b.R), l(a.G, b.G), l(a.B, b.B), l(a.A, b.A)}
	}
	for y := range size {
		for x := range size {
			// Signed distance to the rounded square's edge.
			px, py := float64(x)+0.5, float64(y)+0.5
			half := float64(size) / 2
			dx := math.Abs(px-half) - (half - radius)
			dy := math.Abs(py-half) - (half - radius)
			d := math.Hypot(math.Max(dx, 0), math.Max(dy, 0)) + math.Min(math.Max(dx, dy), 0) - radius
			if d > 0.5 {
				continue
			}
			col := mix(top, bottom, float64(y)/float64(size-1))
			if d > -edge {
				col = ring
			}
			if d > -0.5 { // anti-aliased rim
				col.A = uint8(float64(col.A) * (0.5 - d))
			}
			img.SetNRGBA(x, y, col)
		}
	}
	return img
}

main

Debug: *debug starts with the frame-timing overlay showing, which F3 toggles at any time.

func main() {
	seconds := flag.Float64("seconds", 0, "exit after this many seconds")
	shot := flag.String("shot", "", "write a screenshot to this PNG")
	beep := flag.Bool("beep", false, "play a tone at start")
	debug := flag.Bool("debug", false, "show the frame-timing overlay (F3 toggles it)")
	skin := flag.Bool("skin", false, "start with the texture skin on")
	theme := flag.String("theme", "dark", "starting theme: "+fmt.Sprint(ui.ThemeNames()))
	tab := flag.Int("tab", 0, "the tab of the More widgets window to open on (0-3)")
	flag.Parse()
	err := engine.Run(engine.Config{Title: "Bunyip gallery", Width: 1220, Height: 620, Resizable: true, Debug: *debug, Console: true},
		&gallery{seconds: *seconds, shot: *shot, beep: *beep, skinned: *skin, theme: *theme, startTab: *tab})
	if err != nil {
		fmt.Fprintln(os.Stderr, "gallery:", err)
		os.Exit(1)
	}
}

The particle editor: state

particles.go holds a second, larger widget: a window that tunes a particle.Emitter while it burns. It is the case an immediate-mode interface is built for. Nothing is registered, nothing is bound; every widget writes into a field of the emitter, and when one reports a change the emitter goes straight back to the running system.

Two fields are kept beside the emitter rather than in it. The curves are held as the []lin.Vec2 points ui.CurveEditor edits, and the angles are held in degrees, because that is what anyone tuning an effect thinks in while the engine works in radians.

// editor tunes a particle emitter while it burns: every widget writes
// into the emitter and hands it straight back to the running system, so
// the preview beside the panel is always the effect the numbers
// describe. Save writes the emitter as JSON, the form asset.Emitter
// loads, so a game ships its effects as assets rather than as code.
type editor struct {
	emitter particle.Emitter
	system  *particle.System
	win     ui.Rect
	tab     int
	preset  int
	blend   int
	// The two curves as the points ui.CurveEditor edits, converted back
	// into the emitter whenever they change.
	size  []lin.Vec2
	alpha []lin.Vec2
	// spread and direction are shown in degrees, which is what anyone
	// tuning an effect thinks in; the emitter keeps radians.
	direction float32
	spread    float32
	gravity   float32
	path      string
	status    string
	soft      *gfx.Texture
	at        lin.Vec2
}

The dropdown at the top of the window starts from one of the package's presets. Two of them are burst effects, which would pop once and stop, so they are given a rate instead.

// presets are the starting points the dropdown offers, in the order it
// lists them.
var presets = []string{"Fire", "Smoke", "Sparks", "Confetti"}

// preset returns one of them by index.
func preset(i int) particle.Emitter {
	switch i {
	case 1:
		return particle.Smoke()
	case 2:
		e := particle.Sparks()
		e.Rate, e.Burst = 120, 0 // a steady fountain rather than one pop
		return e
	case 3:
		e := particle.Confetti()
		e.Rate, e.Burst = 40, 0
		return e
	}
	return particle.Fire()
}

The particle editor: loading and applying

load is the one direction, taking an emitter apart into the widgets' state, and apply is the other, putting it back together. SetEmitter retunes the running system without throwing away the particles already in the air, so a slider drag reads as the effect changing rather than restarting.

Points and CurveOf convert between a particle.Curve and the points the curve editor drags.

// newEditor starts the editor on the fire preset at a point in the view.
func newEditor(soft *gfx.Texture, at lin.Vec2) *editor {
	ed := &editor{
		win:  ui.Rect{X: 900, Y: 24, W: 300, H: 560},
		soft: soft,
		at:   at,
		path: "emitter.json",
		// The Look tab holds the curve editors, which is the widget this
		// panel exists to show, so the gallery opens on it.
		tab: 2,
	}
	ed.load(preset(0))
	return ed
}

// load takes an emitter as the one being edited: the texture and the
// position are the editor's, the widgets read their state out of it, and
// a fresh system runs it.
func (ed *editor) load(e particle.Emitter) {
	e.Position = ed.at
	e.Texture = ed.soft
	e.TextureName = "soft-circle" // what asset.Emitter would load
	e.Layer = 1
	if e.Seed == 0 {
		e.Seed = 5 // a fixed stream, so the preview replays the same way
	}
	ed.emitter = e
	ed.size = e.SizeOverLife.Points()
	ed.alpha = e.AlphaOverLife.Points()
	ed.direction = lin.Degrees(e.Direction)
	ed.spread = lin.Degrees(e.Spread)
	ed.gravity = e.Acceleration.Y
	ed.blend = int(e.Blend)
	ed.system = particle.New(e)
}

// apply pushes the widgets' values back into the emitter and retunes the
// running system, keeping the particles already in the air.
func (ed *editor) apply() {
	e := &ed.emitter
	e.Direction = lin.Radians(ed.direction)
	e.Spread = lin.Radians(ed.spread)
	e.Acceleration.Y = ed.gravity
	e.SizeOverLife = particle.CurveOf(ed.size)
	e.AlphaOverLife = particle.CurveOf(ed.alpha)
	e.Blend = gfx.Blend(ed.blend)
	ed.system.SetEmitter(*e)
}

// update advances the preview.
func (ed *editor) update(dt float64) { ed.system.Update(dt) }

// preview draws the effect and a mark where it is emitting from.
func (ed *editor) preview(g *gfx.Graphics) {
	g.SetLayer(0)
	g.StrokeCircle(ed.at.X, ed.at.Y, 5, 1, gfx.RGBA(200, 200, 220, 90))
	ed.system.Draw(g)
	g.SetLayer(0)
}

The particle editor: the window

The tabs keep the panel short: emission, motion and look. Each widget returns whether it changed something, so they are folded together with || and the emitter is rebuilt once, at the end, only if one of them did.

u.CurveEditor is the widget the rest of the gallery does not have: a graph of a curve over a particle's lifetime, edited by dragging its points. Clicking an empty part of the graph adds a point and right-clicking one takes it away, down to the two that anchor the ends. The lo and hi arguments are the range the values may take, which is 0 to 3 for a size multiplier and 0 to 1 for an alpha.

// draw builds the editor window. Every widget reports whether it changed
// something, so the emitter is rebuilt only when one did.
func (ed *editor) draw(u *ui.Context) {
	u.Window("Particle editor", &ed.win, func() {
		if u.Dropdown("Preset", &ed.preset, presets) {
			ed.load(preset(ed.preset))
		}
		u.Tabs([]string{"Emission", "Motion", "Look"}, &ed.tab)
		changed := false
		switch ed.tab {
		case 0:
			changed = u.Slider("Rate (per second)", &ed.emitter.Rate, 0, 400) || changed
			changed = u.Slider("Life from (s)", &ed.emitter.Lifetime.Min, 0.05, 4) || changed
			changed = u.Slider("Life to (s)", &ed.emitter.Lifetime.Max, 0.05, 4) || changed
			changed = u.Slider("Radius", &ed.emitter.Shape.Radius, 0, 80) || changed
			u.Label(fmt.Sprintf("%d alive of %d", ed.system.Alive(), ed.emitter.Max))
		case 1:
			changed = u.Slider("Direction (deg)", &ed.direction, -180, 180) || changed
			changed = u.Slider("Spread (deg)", &ed.spread, 0, 360) || changed
			changed = u.Slider("Speed from", &ed.emitter.Speed.Min, 0, 400) || changed
			changed = u.Slider("Speed to", &ed.emitter.Speed.Max, 0, 400) || changed
			changed = u.Slider("Gravity", &ed.gravity, -400, 400) || changed
			changed = u.Slider("Damping", &ed.emitter.Damping, 0, 4) || changed
		case 2:
			changed = u.Slider("Size from", &ed.emitter.Size.Min, 1, 60) || changed
			changed = u.Slider("Size to", &ed.emitter.Size.Max, 1, 60) || changed
			// Drag a point to bend the curve, click the graph to add one,
			// right-click a point to take it away.
			changed = u.CurveEditor("Size over life", &ed.size, 0, 3, 64) || changed
			changed = u.CurveEditor("Alpha over life", &ed.alpha, 0, 1, 64) || changed
			changed = u.Dropdown("Blend", &ed.blend, []string{"alpha", "add", "multiply", "screen"}) || changed
			if len(ed.emitter.ColorOverLife) == 0 {
				changed = u.ColorPicker("Tint", &ed.emitter.Color) || changed
			} else {
				u.Label("Tinted by a colour-over-life gradient.")
			}
		}
		if changed {
			ed.apply()
		}
		u.Separator()
		u.TextField("File", &ed.path)
		u.Row(2, func() {
			if u.Button("Save") {
				ed.status = ed.save()
			}
			if u.Button("Load") {
				ed.status = ed.loadFile()
			}
		})
		if ed.status != "" {
			u.Label(ed.status)
		}
	})
}

The particle editor: saving an effect as an asset

particle.Save and particle.Load write and read an emitter as JSON. Everything a file can hold is written; the texture cannot be, so TextureName carries the path instead and asset.Emitter loads the image it names. That is the whole point of the editor: a game tunes an effect here, saves it beside its other assets, and loads it at startup without the numbers ever appearing in the code.

// save writes the emitter as JSON and reports what happened, for the
// line under the buttons.
func (ed *editor) save() string {
	data, err := particle.Save(ed.emitter)
	if err != nil {
		return err.Error()
	}
	if err := os.WriteFile(ed.path, data, 0o644); err != nil {
		return err.Error()
	}
	return fmt.Sprintf("Saved %d bytes to %s", len(data), ed.path)
}

// loadFile reads an emitter back. The texture and the position are the
// editor's own, so a file written anywhere still previews here.
func (ed *editor) loadFile() string {
	data, err := os.ReadFile(ed.path)
	if err != nil {
		return err.Error()
	}
	e, err := particle.Load(data)
	if err != nil {
		return err.Error()
	}
	ed.load(e)
	return "Loaded " + ed.path
}

// softCircle uploads the white disc that fades at its edge, the texture
// most glowing particles want.
func softCircle(g *gfx.Graphics) (*gfx.Texture, error) {
	return g.NewTexture(particle.SoftCircle(64), gfx.TextureOptions{Linear: true})
}

What to try

  • Comment out a widget in Draw and see it disappear with no other change: there is nothing to unregister.
  • Give two buttons in Draw the same label inside one panel. Their occurrence order distinguishes them; keep that order stable across frames. u.Row changes layout, not the identity scope.
  • Add a field to the game and a u.Slider for it in Draw; that is the whole procedure for a new control.
  • Change a colour in the theme returned by applyTheme and see every widget follow it.
  • Draw the squares in Draw after u.Begin and watch them cover the interface, then use SetLayer to order them instead.
  • Drag the points of Size over life in the particle editor and watch the flame change shape as you do; click the graph to add a point and right-click one to take it away.
  • Press Save in the particle editor and read the emitter.json it writes. Every field is one an Emitter documents, so the file is worth editing by hand; press Load to see the change.
  • Point asset.Emitter at that file from another game and the effect loads with its texture, with none of its numbers in the code.

Source files

main.go particles.go

The whole directory on GitHub