Documentation
¶
Overview ¶
Package wisp is a 2D pixel-art engine for games that TinyGo compiles to WebAssembly.
It is small on purpose: entities in a structure of arrays, a Canvas2D renderer, keyboard and mouse, a camera, and no dependencies at all. If you can read Go you can read the whole engine in an afternoon.
New is the entry point. Engine.Run takes the frame loop from there:
e := wisp.New(wisp.Config{})
e.LoadImages("/static/img/hero.png")
hero := e.Add(wisp.Sprite{
Height: 32, State: wisp.StateVisible, Width: 32, X: 320, Y: 180,
})
e.CamTarget, e.InputTarget = hero, hero
e.Run(func(dt float64) {
if e.Input.JustPressed(" ") {
e.Impact(e.Feel.Heavy)
}
})
Tuning ¶
Every number that decides how a game feels lives in one Settings value, embedded in the engine. Press M while the game runs and a menu appears on the canvas that edits it, remembers it across reloads, and copies it back out as Go source you paste into your game. That is what the engine is for: a feel is found by trying it, and a rebuild between tries is too slow.
A hit is three named strengths rather than numbers at the call site:
e.Impact(e.Feel.Light) // a connect e.Impact(e.Feel.Heavy) // a finisher
Entities ¶
An entity is an index into every slice at once, so a game reaches into e.X[i] instead of calling a getter. Engine.Add fills a slot and Engine.Delete empties one and keeps it for the next entity; nothing allocates once the slices have grown, which is what keeps the wasm module small and the frame steady.
Keeping the slot is what makes an index worth holding: a delete moves nothing, so an index still means the entity it meant last frame. Iterate to Engine.Slots and skip the holes with Engine.Live; Engine.Count is how many entities there are, which is a smaller number. A slot is reused eventually, so an index that has to outlive a delete — one in a save, or in a packet — travels as an ID and is resolved back with Engine.Index.
The engine owns state bits 0 to 14 — StateVisible and friends, and StateRemote for an entity that somebody else moves — and a game adds its own above bit 15. Engine.RowForState maps a state to the sprite sheet row that draws it, so the engine never has to know what an "attack" is. Engine.Facing is how many ways an entity turns: 2 by default, a left row and a right row, or 4 or 8, which is how the rotations of a top-down sprite key that map.
Sheets ¶
A sprite sheet can be described two ways and the engine draws both the same. The grid convention needs no description at all — every frame is the entity's own size, an animation is a row, and AnimationSettings says how long a frame lasts — and it is what a game gets by saying nothing.
The other is what Aseprite exports: a rectangle and a duration per frame, and named ranges over them. ParseSheet reads that export, and the result goes in Engine.Sheets, one per loaded image:
sheet, err := wisp.ParseSheet(exported)
e.Sheets = []wisp.Sheet{sheet}
e.Play(hero, "walk")
Frames may then be any size, last different lengths, and play forwards, backwards or back and forth. GridSheet builds the grid convention as the same Sheet, so the two are one code path rather than two renderers to keep in step, and a game moving from one to the other does not move its sprites.
Time ¶
A frame and a tick are not the same thing. Engine.Run calls the update it was given once per frame, on however long the frame took: read the keys there, spawn things there, draw a HUD there. The world moves in Engine.Simulate, which runs at TimeSettings.TickRate a second with the same dt every time, so how far anything goes stops depending on how fast the machine drawing it is:
e.Simulate = func(dt float64) {
if e.Input.Down("z") {
e.X[hero] += 0.2 * dt // the same distance on any display
}
}
A frame pays for whole ticks and carries the remainder, so a frame may run two ticks or none. Everything the player only looks at — the camera, the shake, the animations — stays on the frame, because none of it has to agree with anybody.
That the world moves in steps is not something a player should be able to see, so the draw blends each entity between the tick that has run and the one that has not. Engine.DrawPos is where a sprite is on screen and e.X[i] is where the rules say it is; they are up to a tick apart, and anything a game draws over an entity wants the first one. A game that jumps an entity somewhere rather than moving it there says so with Engine.Place, or the jump is blended like any other.
Platforms ¶
The browser half is behind a js and wasm build tag. Everything else builds and tests anywhere, against a headless stand-in, so go test and go vet see the whole API on a developer's machine.
Nothing here is safe for concurrent use. The browser calls the frame loop on one goroutine, which is the only way it is meant to be driven.
Example ¶
A game creates one engine, fills it with entities, and hands the frame loop over. Run blocks until Stop, so main is one call.
package main
import (
"github.com/andygeiss/wisp-engine"
)
func main() {
e := wisp.New(wisp.Config{})
e.LoadImages("/static/img/hero.png")
hero := e.Add(wisp.Sprite{
Height: 32,
State: wisp.StateAnimated | wisp.StateAnimatedLoop | wisp.StateVisible,
Width: 32, X: 320, Y: 180,
})
e.CamTarget, e.InputTarget = hero, hero
e.Run(func(dt float64) {
if e.Input.JustPressed(" ") {
e.Impact(e.Feel.Heavy)
}
})
}
Output:
Index ¶
- Constants
- Variables
- type AnimationSettings
- type AudioSettings
- type CameraSettings
- type Config
- type DebugSettings
- type Direction
- type Engine
- func (e *Engine) Add(s Sprite) (index int)
- func (e *Engine) AddTilemap(m Tilemap)
- func (e *Engine) BoundingBox(i int) (l, t, r, b float64)
- func (e *Engine) Count() int
- func (e *Engine) Delete(i int)
- func (e *Engine) DrawPos(i int) (x, y float64)
- func (e *Engine) Failed() []string
- func (e *Engine) HasCollision(i, j int) bool
- func (e *Engine) HitStop(duration, scale float64)
- func (e *Engine) IDOf(i int) ID
- func (e *Engine) Impact(i ImpactSettings)
- func (e *Engine) Index(id ID) int
- func (e *Engine) Live(i int) bool
- func (e *Engine) LoadImages(paths ...string)
- func (e *Engine) LoadSounds(paths ...string)
- func (e *Engine) Loading() bool
- func (e *Engine) Menu() bool
- func (e *Engine) Move(i int, dx, dy float64)
- func (e *Engine) PauseMusic()
- func (e *Engine) Place(i int, x, y float64)
- func (e *Engine) Play(i int, name string) bool
- func (e *Engine) PlayMusic(index int, volume float64)
- func (e *Engine) PlaySound(index int, volume float64)
- func (e *Engine) Playing(i int) string
- func (e *Engine) Rect(x, y, w, h float64, color string)
- func (e *Engine) Reset()
- func (e *Engine) Run(update func(dt float64))
- func (e *Engine) SetWorldSize(width, height float64)
- func (e *Engine) Shake(magnitude, duration float64)
- func (e *Engine) Shaking() bool
- func (e *Engine) Slots() int
- func (e *Engine) Stats() Stats
- func (e *Engine) Step(elapsed float64)
- func (e *Engine) Stop()
- func (e *Engine) StopMusic()
- func (e *Engine) Stopped() bool
- func (e *Engine) Text(x, y float64, text, color, font, align string)
- func (e *Engine) Tick(dt float64)
- type FeelSettings
- type Frame
- type ID
- type ImpactSettings
- type Input
- func (in *Input) AnyDown(keys ...string) bool
- func (in *Input) Down(key string) bool
- func (in *Input) JustPressed(key string) bool
- func (in *Input) JustReleased(key string) bool
- func (in *Input) Key(key string, isDown bool)
- func (in *Input) MoveAxis() (dx, dy float64)
- func (in *Input) SetModifiers(alt, ctrl, meta, shift bool)
- type RenderSettings
- type Settings
- type ShakeSettings
- type Sheet
- type Sprite
- type Stats
- type Tag
- type Tilemap
- type TimeSettings
- type WorldSettings
Examples ¶
Constants ¶
const ( StateAnimated = uint64(1 << iota) StateAnimatedLoop StateAutoHide StateFaceDown StateFaceLeft StateFaceRight StateFaceUp StateIdle StateMove StateMoveDown StateMoveLeft StateMoveRight StateMoveUp StateVisible // StateRemote marks an entity that somebody else moves — a server, a // replay. The engine keeps its row in step with its state bits but never // moves it and never rewrites its pose, so a client can hold the move bits // a server sent, for the camera's look-ahead, without the entity walking // off on its own. It is bit 14, and it comes last rather than in // alphabetical order so that every bit before it keeps the number it had. StateRemote )
The engine's state bits. It reads the ones it knows; a game adds its own above bit 15, which leaves bit 15 spare for the engine to grow into.
const KeyNone = "-"
KeyNone turns off a key an engine would otherwise claim. Give it to Config.MenuKey to ship a game with no tuning menu, or to Config.FullscreenKey to keep F for yourself.
const MaskMove = StateMoveDown | StateMoveLeft | StateMoveRight | StateMoveUp
MaskMove is the four bits that say an entity is being pushed somewhere.
const MaskPose = StateFaceDown | StateFaceLeft | StateFaceRight | StateFaceUp | StateIdle | StateMove
MaskPose is the six bits that make a pose: the four facings, idle and moving. A game usually starts its Engine.RowMask with it. At the default Engine.Facing of 2 the vertical facings are never set, so the keys of a two-row sheet's map are the four bits they always were.
Variables ¶
var ( // ErrSheetTooLarge means the input is over maxSheetBytes. ErrSheetTooLarge = errors.New("wisp: sheet input too large") // ErrSheetSyntax means the bytes are not JSON, or are JSON that is not an // Aseprite json-array export. ErrSheetSyntax = errors.New("wisp: malformed sheet") // ErrSheetTooDeep means the nesting is over maxSheetDepth. ErrSheetTooDeep = errors.New("wisp: sheet nested too deeply") // ErrSheetTooMany means there are more frames or tags than a sheet may // hold. ErrSheetTooMany = errors.New("wisp: too many frames or tags in sheet") // ErrSheetRange means a number is not finite, a frame's rectangle leaves // the image, or a tag names frames that do not exist. ErrSheetRange = errors.New("wisp: sheet value out of range") )
The failures a caller can branch on. Everything the parser rejects comes back wrapped around one of these, so a game can tell "this is not the file I meant" from "this file is too big to be one".
Functions ¶
This section is empty.
Types ¶
type AnimationSettings ¶
type AnimationSettings struct {
// CullOffscreen stops an entity outside the view from advancing its
// animation. It saves work in a crowded world, and it means an enemy walks
// on screen mid-stride instead of from frame zero.
CullOffscreen bool
// FrameCount is how many frames a grid sheet puts in one row.
FrameCount int
// FrameDuration is how long one frame lasts, in milliseconds.
FrameDuration float64
}
AnimationSettings decides how sprite animations run when a sheet does not say otherwise.
type AudioSettings ¶
type AudioSettings struct {
// MusicVolume scales the music, on top of Volume.
MusicVolume float64
// PitchJitter randomises playback speed by up to this fraction, so the
// same hit sound does not sound identical twenty times in a row.
PitchJitter float64
// SfxVolume scales the sound effects, on top of Volume.
SfxVolume float64
// Voices is how many copies of one sound may play at once. One copy means
// three monsters dying together make one sound.
Voices int
// Volume scales everything.
Volume float64
}
AudioSettings decides how loud things are and how much they vary.
type CameraSettings ¶
type CameraSettings struct {
// DeadzoneHeight is how far the target may move up or down before the
// camera follows, in pixels.
DeadzoneHeight float64
// DeadzoneWidth is the same across.
DeadzoneWidth float64
// Lookahead pushes the camera this many pixels ahead of the way the target
// is moving, so you see where you are going.
Lookahead float64
// Smoothing is how fast the camera catches up with its target, per
// second: 0 snaps to it, 8 is a soft follow, 30 is almost a snap. The
// engine closes the gap by 1-exp(-Smoothing*dt), so the feel does not
// change between a 60 Hz screen and a 144 Hz one.
Smoothing float64
// Snap rounds the camera to whole pixels. Off, tile edges show seams.
Snap bool
}
CameraSettings decides how the camera follows its target.
type Config ¶
type Config struct {
// FullscreenKey toggles fullscreen. Empty means "f". [KeyNone] leaves
// fullscreen out, so a game may use F for itself.
FullscreenKey string
// Height of the drawing surface, in pixels. 0 means 360.
Height int
// MenuKey opens and closes the tuning menu. Empty means "m". [KeyNone]
// leaves the menu out, which is what a shipped game wants: with no menu
// the engine also stops reading and writing the browser's storage.
MenuKey string
// Mount is the CSS selector the canvas is appended to. Empty means
// "main", falling back to the document body.
Mount string
// Width of the drawing surface, in pixels. 0 means 640. 640x360 scales to
// 1080p and 4K by whole numbers, so nothing blurs.
Width int
}
Config is what New needs. Every field has a working default, so wisp.New(wisp.Config{}) gives a 640x360 engine with the tuning menu on M and fullscreen on F.
type DebugSettings ¶
type DebugSettings struct {
// ShowHitBoxes outlines every entity's hit box, the rectangle
// [Engine.BoundingBox] returns, over the scene. It is where the rules have
// the entity rather than where its sprite is drawn, so with
// [RenderSettings.Interpolate] on the box runs up to a tick ahead of the
// sprite — that gap is the blend made visible. An invisible entity's box
// is drawn too, because an unseen collider is what somebody switching this
// on is looking for.
ShowHitBoxes bool
// ShowMetrics draws the frame-time and memory overlay.
ShowMetrics bool
}
DebugSettings turns the engine's own overlays on.
type Direction ¶
type Direction uint8
Direction is the order a Tag plays its frames in. It matches Aseprite's own three, and it is spelled the way the export spells it.
type Engine ¶
type Engine struct {
Settings
// Alpha is the opacity, 0 to 1.
Alpha []float64
// FrameOffset is the animation frame showing now.
FrameOffset []int
// FrameTime is the milliseconds spent on the current frame.
FrameTime []float64
// ImageColumn is the sheet column of the sprite's first frame.
ImageColumn []int
// ImageIndex is which loaded image, in [Engine.LoadImages] order.
ImageIndex []int
// ImageRow is which animation is playing: a row on the grid convention, a
// tag's index when the image has a [Sheet]. RowForState overwrites it for
// entities whose state has a bit in RowMask, and [Engine.Play] writes the
// tag it looked up.
ImageRow []int
// PrevX is where the entity was when the tick running now began, and
// PrevY the same. [Engine.DrawPos] is what reads them, to place a sprite
// between two ticks; [Engine.Place] is how a game writes them, for an
// entity that jumps somewhere rather than travelling there.
PrevX []float64
PrevY []float64
// ScreenSpace draws the entity in canvas pixels, past the camera.
ScreenSpace []bool
// SpeedFactor multiplies World.Speed for this entity.
SpeedFactor []float64
// SpriteHeight is the drawn height in pixels.
SpriteHeight []float64
// SpriteWidth is the drawn width in pixels.
SpriteWidth []float64
// State is the entity's state bits. The engine reads the ones it knows; a
// game adds its own above bit 15.
State []uint64
// X is the world x of the sprite's centre.
X []float64
// Y is the world y of the sprite's centre.
Y []float64
// Z is the draw layer. Lower draws first.
Z []int
// Facing is how many ways [Engine.Move] turns an entity: 2, 4 or 8, and 0
// means 2. At 2 only the horizontal axis turns it, which is what a sheet
// with a left row and a right row wants, and what every game had until
// now. At 4 a move sets one of the four facing bits; at 8 a diagonal sets
// two, one from each axis, so the eight rotations of a top-down sprite
// key a RowForState map through [MaskPose]. Both halves of a networked
// game set the same value, because the server is what turns a player.
Facing int
// RowForState maps a state, masked by RowMask, to the sheet row that draws
// it. A game fills it once; the engine only looks things up, so it never
// has to know what an "attack" is.
RowForState map[uint64]int
// RowMask picks the bits RowForState is keyed by. Start it with [MaskPose]
// and add the game's own action bits.
RowMask uint64
// Sheets describes the loaded images: one entry per image, in the order
// [Engine.LoadImages] took them. A game fills it once, from [ParseSheet]
// or [GridSheet]. An image with no entry — or a zero Sheet, or one with no
// tags — draws on the grid convention instead, so a game that never had a
// sheet never needs one. See [Sheet].
Sheets []Sheet
// CamTarget is the entity the camera follows, or -1 for none.
CamTarget int
// InputTarget is the entity the movement keys drive, or -1 for none.
InputTarget int
// CamX is the world x of the view's left edge, before the shake. Read it
// for parallax; writing it lasts until the next frame.
CamX float64
// CamY is the world y of the view's top edge, before the shake.
CamY float64
// CamShakeX is the shake offset used when drawing, in pixels. It does not
// move the world, so a hit box never moves with a shake.
CamShakeX float64
// CamShakeY is the vertical shake offset.
CamShakeY float64
// Height of the drawing surface in pixels. Read it; do not write it.
Height float64
// Width of the drawing surface in pixels. Read it; do not write it.
Width float64
// Input is the keyboard and the mouse. Ask it Down, JustPressed or
// JustReleased; nothing here ever needs clearing by hand.
Input Input
// Paused holds the world still. The scene still draws and the game still
// gets its update with a dt of 0, so it can show a menu and read the keys
// that leave it, but nothing moves and no animation advances.
Paused bool
// RenderUI draws screen-space content after the entities: a HUD, a score,
// a menu. Use [Engine.Rect] and [Engine.Text]. The engine's own tuning
// menu draws after this, so it stays on top.
RenderUI func()
// Simulate moves the game's own world, and runs at the fixed rate
// [TimeSettings.TickRate] sets rather than once per frame. Put movement,
// physics and anything a rule depends on here: it is called with the same
// dt every time, so the same input twice gives the same answer twice,
// which is what a replay — and one day a rollback — needs.
//
// Read held keys here with [Input.Down]. An edge belongs in the update
// given to [Engine.Run], which runs once per frame: a frame can carry two
// ticks or none, so a JustPressed read here fires twice or not at all.
Simulate func(dt float64)
// contains filtered or unexported fields
}
Engine is one game's world: its entities, its camera, its input, and the Settings that decide how all of it feels.
Entities live in a structure of arrays. Every attribute is its own slice and an entity is an index into all of them, so a game reaches into e.X[i] instead of calling a getter, and adding an entity allocates nothing once the slices have grown. An index keeps meaning the same entity until that entity is deleted; see ID for a name that outlives one.
Settings is embedded, so the knob you nudge while tuning is short — e.Feel.Heavy.ShakeMagnitude — and the whole set is still one value the menu can save, reset and print.
Use New. An Engine is not safe for concurrent use: the browser calls the frame loop on one goroutine, which is the only way it is meant to be driven.
func New ¶
New creates an engine with the default settings and no entities. It touches no browser API; the canvas appears when Engine.Run starts.
func (*Engine) Add ¶
Add adds an entity and returns its index. The sprite is drawn centred on (X, Y).
The index is stable: it keeps meaning this entity until the entity is deleted. It is reused after that, so an index held across a delete needs an ID instead — see Engine.IDOf.
func (*Engine) AddTilemap ¶
AddTilemap adds one visible entity per tile. Tiles do not move and carry no state bits, so they cost a draw and nothing else.
func (*Engine) BoundingBox ¶
BoundingBox returns the left, top, right and bottom edge of entity i's hit box: the sprite shrunk by WorldSettings.HitBoxMargin on all four sides, so sprites have to visibly overlap before they collide. A margin past half the sprite gives a box of zero size, which never collides.
It panics when i is not an entity, which is a programmer error.
func (*Engine) Count ¶
Count returns how many entities there are.
It is not the range to iterate. A deleted entity leaves its slot behind, so the arrays can be longer than the count — use Engine.Slots for the range and Engine.Live to skip the holes.
func (*Engine) Delete ¶
Delete removes entity i and keeps its slot for the next Engine.Add. No other index moves, so a game — or a packet — may hold an index across a delete of somebody else.
The slot is emptied rather than compacted away: it becomes a 0x0 invisible entity with no state bits, which the update and the draw skip on the tests they already do. Deleting the same entity twice does nothing the second time, so a game holding an index does not have to track whether it already used it. CamTarget and InputTarget are cleared to -1 when they point here.
It panics when i is outside the arrays, which is a programmer error.
func (*Engine) DrawPos ¶ added in v0.3.0
DrawPos returns where entity i is drawn this frame: its own position, blended back toward where it stood when the current tick began.
The simulation moves in whole ticks and a frame lands between two of them. Reading e.X[i] straight into the draw therefore shows the world in tick-sized steps — a frame that carries two ticks or none is a pop of about two pixels at the default speed, which is a third of the frames on a 144 Hz display. Blending is what spends the frame's own position on it.
It costs up to one tick of lag, because it draws between two positions the simulation has already been rather than guessing at one it has not. A guess is wrong every time something turns around, and a game full of wrong guesses is worse than a game a sixtieth of a second behind.
It is the drawn position and nothing else. Collision, the camera's bounds and every rule read e.X[i], which is where the simulation actually is. A game drawing its own thing over an entity — a health bar, a name — wants this one, or it trails the sprite it is labelling by a tick.
It panics when i is outside the arrays, which is a programmer error.
func (*Engine) Failed ¶
Failed returns the asset paths that did not load. It is empty when everything arrived. Check it once Engine.Loading turns false: a missing file used to leave the engine on "Loading..." for ever.
func (*Engine) HasCollision ¶
HasCollision reports whether the hit boxes of entities i and j overlap.
func (*Engine) HitStop ¶
HitStop slows the world to scale for duration milliseconds. A scale of 0 is a freeze; 0.1 is slow motion. The longer of the running stop and this one wins, so a second hit during a freeze cannot cut it short. A duration of 0 or less cancels the stop that is running.
func (*Engine) Impact ¶
func (e *Engine) Impact(i ImpactSettings)
Impact applies one hit's worth of feel: a hit stop and a shake, in the strength the game asked for.
e.Impact(e.Feel.Heavy)
The numbers live in Settings, so the call site carries none and the menu can change how every heavy hit in the game lands.
Example ¶
A hit is a named strength, not a number at the call site. Change what Heavy means and every finisher in the game changes with it.
package main
import (
"fmt"
"github.com/andygeiss/wisp-engine"
)
func main() {
e := wisp.New(wisp.Config{})
e.Impact(e.Feel.Heavy)
fmt.Println(e.Stopped(), e.Shaking())
}
Output: true true
func (*Engine) Index ¶
Index returns the index id names, or -1 when that entity is gone — deleted, or cleared by Engine.Reset. A slot reused since is gone too, which is the whole reason an ID is not just an index.
func (*Engine) Live ¶
Live reports whether slot i holds an entity. It is false for a deleted slot and for an index outside the arrays, so it is safe to ask about anything.
func (*Engine) LoadImages ¶
LoadImages starts loading each image. Engine.Loading stays true until every image and sound has arrived or failed.
func (*Engine) LoadSounds ¶
LoadSounds starts loading each sound. The index of a sound is its position in this call, which is what Engine.PlaySound takes.
func (*Engine) Loading ¶
Loading reports whether images and sounds are still arriving. A game shows its own loading screen while it is true; the engine draws one too.
func (*Engine) Menu ¶
Menu reports whether the tuning menu is open. A game that draws its own overlay can use it to get out of the way.
func (*Engine) Move ¶ added in v0.3.0
Move steers entity i by an axis. dx and dy are each negative, zero or positive, and only the sign counts: a value of 100 pushes exactly as hard as a value of 1, so an axis that arrived from somewhere untrusted needs no clamping first. It sets the move bits the next Engine.Tick moves the entity by, and turns the entity to face the way it is going, one of Engine.Facing ways — unless an action outside the pose bits, an attack or a dash, is running, so a sprite does not turn mid-swing.
The engine calls it every tick for Engine.InputTarget, with the movement keys. A server calls it for every player with the axis that player sent, and a game calls it to drive anything else the way the keys drive the player. The bits hold until the next call, so once a frame is enough.
It panics when i is outside the arrays, which is a programmer error.
func (*Engine) PauseMusic ¶
func (e *Engine) PauseMusic()
PauseMusic stops the music where it is. Engine.PlayMusic picks it up from there, which is what a pause menu wants.
func (*Engine) Place ¶ added in v0.3.0
Place puts entity i at (x, y) without drawing the way there.
A sprite that jumps — a respawn, a teleport, the far side of a door — is a jump the draw cannot tell from a very fast tick, so Engine.DrawPos would smear it across the gap for a tick. Writing e.X[i] moves the entity; this moves the entity and forgets where it was.
It panics when i is outside the arrays, which is a programmer error.
func (*Engine) Play ¶
Play starts the animation named name on entity i, from its first frame, and reports whether the sheet had it.
It writes the tag's index into Engine.ImageRow, which is the same place RowForState writes a row: an animation is an animation, whether a name or a state bit picked it. Nothing happens and it returns false when the entity's image has no sheet, or the sheet has no such tag — a game may then fall back to a row it does know.
Play does not make the entity animate. Set StateAnimated, and StateAnimatedLoop for one that repeats, the way the grid path does.
func (*Engine) PlayMusic ¶
PlayMusic starts sound index looping, or does nothing if it is already playing, so a game may call it every frame.
func (*Engine) PlaySound ¶
PlaySound plays sound index once. A re-trigger takes the next voice, so hitting three monsters in one frame is three sounds rather than one.
func (*Engine) Playing ¶
Playing returns the name of the animation entity i is on, or "" when its image has no sheet or the row is not a tag.
func (*Engine) Rect ¶
Rect fills a rectangle in screen space. Call it from Engine.RenderUI; a menu needs a backdrop, and the engine draws no shapes of its own.
func (*Engine) Reset ¶
func (e *Engine) Reset()
Reset removes every entity, keeping the capacity the slices have grown to. A game calls it once per scene. It leaves the settings and the camera alone.
Every ID handed out before it stops resolving, so a stale one cannot come back to life as an entity in the new scene.
func (*Engine) Run ¶
Run creates the canvas, starts the frame loop, and blocks until Engine.Stop is called. update runs once per frame with the time the world moved by, before the engine moves entities and draws.
It blocks so a game's main is one call: keeping the Go runtime alive used to be the caller's job and is the engine's now.
func (*Engine) SetWorldSize ¶
SetWorldSize sets the area the camera may show. A world smaller than the canvas is centred. New sets it to the canvas size.
func (*Engine) Shake ¶
Shake throws the camera up to magnitude pixels away for duration milliseconds, fading out along ShakeSettings.Decay. A stronger shake replaces a weaker one; a weaker one is ignored while one is running. A duration of 0 or less cancels the shake that is running.
func (*Engine) Slots ¶
Slots returns the length of every entity array, which is the range to iterate:
for i := range e.Slots() {
if !e.Live(i) {
continue
}
...
}
It only ever grows while entities exist, because a deleted entity's slot is kept for the next one. Engine.Reset returns it to zero.
func (*Engine) Step ¶
Step advances the world by one frame.
elapsed is the real milliseconds since the last frame. The game's update, given to Engine.Run, is called with the time the world actually moved by, which is 0 while the world is paused or held by a hit stop — so a game that moves things by dt stops for free, and a game that reads keys still reads them.
The simulation does not run on that time. Step spends it on whole Engine.Tick calls of a fixed length and carries the remainder, so how far anything moves stops depending on how long the frame took. Everything the player only looks at — the camera, the shake, the animations, the menu — stays on the frame, because it has nothing to agree with anybody about.
Engine.Run calls Step once per frame. A test calls it directly.
Example ¶
Step advances the world by one frame, spending the frame's time on whole simulation ticks. A hit stop holds the world, so a game that moves things by dt stops for free.
package main
import (
"fmt"
"github.com/andygeiss/wisp-engine"
)
func main() {
e := wisp.New(wisp.Config{})
i := e.Add(wisp.Sprite{
Height: 32, State: wisp.StateVisible | wisp.StateMoveRight, Width: 32,
})
// A tick is 1000/TickRate ms — 16.6 at the default 60 — so a 20 ms frame
// pays for one and carries the remainder into the next.
e.Step(20)
moved := e.X[i]
e.Impact(e.Feel.Heavy)
e.Step(20)
fmt.Println(moved > 0, e.X[i] == moved)
}
Output: true true
func (*Engine) Stop ¶
func (e *Engine) Stop()
Stop ends the frame loop and releases the callbacks the browser holds. It is safe to call more than once, and safe to call from update.
func (*Engine) Text ¶
Text draws text in screen space. Call it from Engine.RenderUI.
func (*Engine) Tick ¶
Tick advances the simulation by exactly dt milliseconds: the game's Engine.Simulate first, then the engine's own movement.
Engine.Step calls it as often as the frame's time paid for, which is what a game wants. Call it directly to run the simulation on a clock that is not this machine's — a server's tick, or a replay of one whose input you already have — which is why it takes the step instead of reading a clock.
type FeelSettings ¶
type FeelSettings struct {
Heavy ImpactSettings
Light ImpactSettings
Medium ImpactSettings
Shake ShakeSettings
}
FeelSettings is the impact of a hit, in three strengths a game picks from, plus the shake curve all three share.
A game says which strength something is worth — e.Impact(e.Feel.Heavy) — and never writes a number at the call site. The numbers live here, where the menu can reach them.
type Frame ¶
type Frame struct {
// Duration is how long this frame shows, in milliseconds.
Duration float64
// H is the frame's height on the image.
H float64
// W is the frame's width on the image.
W float64
// X is the frame's left edge on the image.
X float64
// Y is the frame's top edge on the image.
Y float64
}
Frame is one frame: where it is on the image, and how long it shows.
The rectangle is in image pixels, which is why a packed or trimmed sheet works without the engine knowing it is one. Duration is milliseconds.
type ID ¶
type ID uint64
ID names one entity for as long as it lives, and never names another one.
An index is a position in the entity arrays. It is stable — a delete leaves its slot where it is — but it is not unique for ever, because the next Add reuses the slot. An ID carries the generation of the entity that filled it as well, so an ID left over from the entity before resolves to nothing rather than to a stranger.
Hold an ID when the entity has to survive being deleted by somebody else: across frames, between a game and its server, inside a packet. Do the work through the index it resolves to, which is what every array is keyed by, and resolve once rather than in a loop.
The zero ID names nothing.
type ImpactSettings ¶
type ImpactSettings struct {
// HitStopDuration is how long the world holds still, in milliseconds.
HitStopDuration float64
// HitStopScale is how fast time runs while it holds: 0 is a freeze, 0.1 is
// slow motion, 1 does nothing.
HitStopScale float64
// ShakeDuration is how long the shake lasts, in milliseconds.
ShakeDuration float64
// ShakeMagnitude is how far the camera may be thrown, in pixels.
ShakeMagnitude float64
}
ImpactSettings is one hit's worth of feel: how long time stops, and how hard the screen shakes.
type Input ¶
type Input struct {
// Alt, Ctrl, Meta and Shift are the modifier keys, as of the last event.
Alt bool
Ctrl bool
Meta bool
Shift bool
// MouseDown is true while a mouse button is held on the canvas.
MouseDown bool
// MouseX and MouseY are the pointer in world coordinates.
MouseX float64
MouseY float64
// Started turns true on the first key or click and stays true. Browsers
// refuse to play sound before it, so a game waits for it.
Started bool
// contains filtered or unexported fields
}
Input is the keyboard and mouse as of this frame.
A key is named the way the browser names it: one lower-case character for a letter or digit ("w", "3"), and the DOM name for everything else ("ArrowLeft", "Enter", "Escape", "Tab"). Space is " ".
Input.Down answers "is it held", Input.JustPressed answers "did it go down this frame". The second one is why a game no longer has to clear a key by hand after acting on it.
func (*Input) Down ¶
Down reports whether key is held right now.
It answers false for every key while the tuning menu is open. The menu takes the keyboard so a game cannot be steered underneath it; the game's own logic keeps running, which is what lets a camera be judged while it is tuned.
func (*Input) JustPressed ¶
JustPressed reports whether key went down during this frame. It is true for exactly one frame, so a game never has to clear a key by hand.
func (*Input) JustReleased ¶
JustReleased reports whether key came up during this frame.
func (*Input) Key ¶
Key records a press or a release. The browser half calls it; a test calls it to drive the engine without a browser.
func (*Input) MoveAxis ¶ added in v0.3.0
MoveAxis returns the direction the movement keys are asking for, each component -1, 0 or 1. WASD and the arrow keys both work, so a player who reaches for either is right. It answers 0, 0 while the tuning menu is open, like every other read of the keys.
Engine.Move takes it, which is how the engine drives Engine.InputTarget. A client sends it to a server as what the player is trying to do.
func (*Input) SetModifiers ¶
SetModifiers records the modifier keys an event arrived with. The browser half calls it; a test calls it to drive the menu's coarse and fine steps.
type RenderSettings ¶
type RenderSettings struct {
// Background is the CSS colour the canvas is cleared to. Empty clears to
// transparent and lets the page show through.
Background string
// Interpolate draws each entity between the tick that has run and the one
// that has not, so a frame carrying two ticks or none does not show a pop.
// It costs the draw a little arithmetic and shows the world up to one tick
// in the past. Off, every sprite sits exactly where the simulation put it,
// and moves in the steps the simulation moved it in.
//
// It smooths what [Engine.Simulate] moves. An entity moved from the update
// [Engine.Run] was given is already frame-accurate, and blending drags it
// backwards instead — which is one more reason movement belongs in a tick.
Interpolate bool
// PixelSnap rounds sprite positions to whole pixels.
PixelSnap bool
// Smoothing turns on the canvas's own interpolation. Pixel art wants it
// off; leaving it on is what makes a scaled sprite look blurry.
Smoothing bool
}
RenderSettings decides how the picture is drawn.
type Settings ¶
type Settings struct {
Animation AnimationSettings
Audio AudioSettings
Camera CameraSettings
Debug DebugSettings
Feel FeelSettings
Render RenderSettings
Time TimeSettings
World WorldSettings
}
Settings is every knob that decides how a game feels. One value, one place: Defaults fills it, the menu edits it while the game runs, and Settings.GoLiteral hands it back as Go source to paste into your game.
Every field is a number or a bool, which is what lets one menu edit all of them without knowing what any of them mean.
func Defaults ¶
func Defaults() Settings
Defaults returns the settings a new engine starts with. They are the numbers the game in game-jam-template was balanced around, so a game that changes nothing plays the way that one does.
func (Settings) GoLiteral ¶
GoLiteral returns the settings as Go source: a complete wisp.Settings composite literal, aligned the way gofmt would align it, ready to paste into a game.
Every field is there, so pasting it over an old one leaves nothing behind — a partial literal would silently zero every knob it omitted. Settings.Render Background is the one field it does not print, because it is a colour rather than a number and the menu cannot edit it.
Example ¶
GoLiteral is what the tuning menu's copy key puts on the clipboard: the whole settings value as Go source, ready to paste into a game.
package main
import (
"fmt"
"strings"
"github.com/andygeiss/wisp-engine"
)
func main() {
s := wisp.Defaults()
s.Feel.Heavy.ShakeMagnitude = 14
src := s.GoLiteral()
for line := range strings.Lines(src) {
if strings.Contains(line, "ShakeMagnitude") && strings.Contains(line, "14") {
fmt.Print(strings.TrimSpace(line))
break
}
}
}
Output: ShakeMagnitude: 14,
func (Settings) MarshalText ¶
MarshalText writes the settings as one name=value line per knob. It is what the browser remembers between reloads.
It is not JSON: encoding/json needs reflection, and reflection is what makes a TinyGo binary large enough to notice.
func (*Settings) UnmarshalText ¶
UnmarshalText reads what MarshalText wrote, over the top of whatever the settings already hold.
It never fails. A name it does not know is skipped, a name that is missing keeps its current value, and every value is clamped to its own range — so an old save, a hand-edited one, or a Time.Scale of 1e308 typed into the browser's console cannot leave a game unplayable.
type ShakeSettings ¶
type ShakeSettings struct {
// Decay is the shape of the fade: 1 falls off in a straight line, 2 drops
// fast then lingers, 0.5 holds then stops.
Decay float64
// Frequency is how many times a second the camera is thrown somewhere new.
// Below the frame rate it reads as a rumble, above it as noise.
Frequency float64
// IgnoresHitStop keeps the shake moving while a hit stop holds the world
// still, so a freeze still feels violent.
IgnoresHitStop bool
// Snap rounds the shake to whole pixels, like the camera.
Snap bool
}
ShakeSettings shapes every shake, whatever set it off.
type Sheet ¶
type Sheet struct {
// Frames is every frame on the image, in the order the export lists them.
Frames []Frame
// H is the image's height, as the export measured it. The parser checks
// every frame against it, so a rectangle can never point off the image.
H float64
// Tags is the named animations. [Engine.ImageRow] indexes it.
Tags []Tag
// W is the image's width, as the export measured it.
W float64
}
Sheet is a parsed sprite sheet: every frame on one image, and the named animations over them.
Attach one per image through Engine.Sheets. The zero Sheet means the grid convention, so an image nobody described keeps drawing the way it always did.
ParseSheet and GridSheet both return a Sheet the draw can trust, and they are the two ways to get one. A Sheet built by hand must hold up the same promise — every tag's From and To index Frames — because the draw reads a frame per entity per pass and checking it there would be a check per sprite per frame. Breaking it panics, the way an out-of-range index into any of the entity arrays does.
func GridSheet ¶
GridSheet builds the grid convention as a Sheet: cols frames of w by h pixels per row, rows rows, every frame lasting ms milliseconds.
It exists to prove the two descriptions are one. A game on the grid does not need it — leaving Engine.Sheets empty draws the same pixels — but a game moving to Aseprite can build the sheet it already had and compare.
The tags are the rows, in order and unnamed, because a grid says nothing about what a row is for. Naming them is what the export adds.
func ParseSheet ¶
ParseSheet reads an Aseprite sprite sheet export and returns the Sheet it describes.
Produce the input with Aseprite's own exporter:
aseprite -b hero.aseprite --sheet hero.png --sheet-type rows \ --data hero.json --format json-array --list-tags
The format must be json-array and the tags must be listed; without --list-tags an export carries no animations and every frame is one nameless run. Fields ParseSheet does not know are skipped, so a newer Aseprite is not a breaking change.
The returned Sheet is checked, not merely parsed: every frame's rectangle is inside the image, every tag names frames that exist, and every duration is a real number of milliseconds. A game may attach the result to Engine.Sheets without looking at it.
Example ¶
package main
import (
"fmt"
"github.com/andygeiss/wisp-engine"
)
func main() {
// In a game this is the file Aseprite exported, fetched next to the PNG.
const export = `{
"frames": [
{"frame": {"x": 0, "y": 0, "w": 32, "h": 32}, "duration": 100},
{"frame": {"x": 32, "y": 0, "w": 32, "h": 32}, "duration": 100}
],
"meta": {
"size": {"w": 64, "h": 32},
"frameTags": [{"name": "walk", "from": 0, "to": 1, "direction": "forward"}]
}
}`
sheet, err := wisp.ParseSheet([]byte(export))
if err != nil {
panic(err)
}
e := wisp.New(wisp.Config{})
e.Sheets = []wisp.Sheet{sheet}
hero := e.Add(wisp.Sprite{
Height: 32, Width: 32,
State: wisp.StateAnimated | wisp.StateAnimatedLoop | wisp.StateVisible,
})
e.Play(hero, "walk")
fmt.Println(len(sheet.Frames), sheet.Tags[0].Name, e.Playing(hero))
}
Output: 2 walk walk
func (*Sheet) Lookup ¶
Lookup returns the index of the tag named name, or -1 when the sheet has no such animation.
It is a scan, because a sheet holds a handful of tags and a map would cost more to build than it ever saves. Resolve a name once and keep the index if a game plays the same animation every frame.
type Sprite ¶
type Sprite struct {
// Alpha is the opacity, 0 to 1. Because an entity added invisible is
// almost always a mistake, 0 here means 1; set e.Alpha[i] afterwards for
// an entity that really should start transparent.
Alpha float64
// Column is the sheet column the sprite starts at. The animation frame is
// added to it, so one sheet holds every animation of every sprite.
Column int
// Height of the sprite in pixels. It is also the sheet's cell height.
Height float64
// Image is which loaded image, in [Engine.LoadImages] order.
Image int
// Row is the sheet row, which is one animation.
Row int
// ScreenSpace draws the sprite in canvas pixels, past the camera. Use it
// for a HUD.
ScreenSpace bool
// SpeedFactor multiplies [WorldSettings.Speed] for this entity. 0 means 1.
SpeedFactor float64
// State is the entity's state bits.
State uint64
// Width of the sprite in pixels. It is also the sheet's cell width.
Width float64
// X is the world x of the sprite's centre.
X float64
// Y is the world y of the sprite's centre.
Y float64
// Z is the draw layer. Lower draws first.
Z int
}
Sprite describes an entity at the moment it is added. Its zero value is a 0x0 invisible entity at the origin, so fill in what matters and leave the rest.
type Stats ¶
type Stats struct {
// Budget is one frame's worth of milliseconds, measured from the display
// rather than assumed: 16.7 at 60 Hz, 8.3 at 120.
Budget float64
// DrawMs is how long the last frame spent drawing.
DrawMs float64
// Drawn is how many sprites the last frame actually painted, after the
// camera threw the off-screen ones away.
Drawn int
// Dropped counts frames that took more than one and a half budgets.
Dropped int
// Entities is how many entities exist, drawn or not.
Entities int
// FPS is the frame rate over the window.
FPS float64
// FrameMs is the last frame, wall to wall.
FrameMs float64
// GoHeapBytes is what the Go heap holds. Under TinyGo this is a subset of
// what ordinary Go reports; the overlay says so rather than guessing.
GoHeapBytes uint64
// GPU names the graphics card, or says why it cannot.
GPU string
// JSHeapBytes is the JavaScript heap. Chromium only; 0 elsewhere.
JSHeapBytes uint64
// Load is the share of the frame budget the main thread spent working,
// 0 to 1 and beyond. It is the closest honest thing to processor load.
Load float64
// Late is the frame time, in milliseconds, at which a frame counts as
// missed: [Stats.Budget] times a tolerance. Compare a percentile against
// this rather than against Budget, which half of a healthy window exceeds
// by construction. 0 when the budget is not known yet.
Late float64
// LongFrames counts frames the browser itself reported as too long.
// Chromium only; -1 where the browser does not offer it.
LongFrames int
// P50Ms and P99Ms are the middle and the near-worst frame in the window.
// P99 is the number that decides whether a game feels smooth; an average
// hides exactly the frames a player notices.
P50Ms float64
P99Ms float64
// SortMs is how long the last frame spent putting the entities in draw
// order. It is separate from [Stats.DrawMs] because the two answer
// different questions: the sort is a function of how many entities exist
// and a different renderer would not change it, while the draw is what a
// different renderer would replace.
SortMs float64
// UpdateMs is how long the last frame spent on the world.
UpdateMs float64
// WasmBytes is the module's own size, which is the number this engine
// exists to keep small.
WasmBytes int
// WasmMemoryBytes is the WebAssembly linear memory the module holds. It
// only ever grows, and it grows in 64 KiB pages.
WasmMemoryBytes uint64
}
Stats is what the last two seconds cost. Engine.Stats returns it, and the overlay draws it.
Three numbers a person asks for are missing, and they are missing because no browser offers them: processor load, graphics-card load, and the graphics card's own frame time. What is here instead is the honest neighbour of each — how much of the frame budget the main thread spent, and how much work was asked of the renderer.
type Tag ¶
type Tag struct {
// Direction is the play order.
Direction Direction
// From is the first frame of the animation.
From int
// Name is what the animation is called in Aseprite. [Engine.Play] takes
// this. A grid sheet has no names, so its tags carry an empty one.
Name string
// To is the last frame of the animation, and it is included.
To int
}
Tag is one named animation: a run of frames and the order to play them in.
From and To are both inclusive indices into Sheet.Frames, which is how Aseprite writes them. A tag of one frame has From equal to To.
type Tilemap ¶
type Tilemap struct {
// Height of one tile in pixels.
Height float64
// Image is which loaded image the tileset comes from.
Image int
// Rows and Cols are the shape of the map.
Cols int
Rows int
// TilesetCols and TilesetRows are the shape of the tileset image, in
// tiles. A tile index outside them leaves the cell empty.
TilesetCols int
TilesetRows int
// Tiles is one tile index per cell, row by row. A negative index leaves
// the cell empty, and a short slice fills what it can.
Tiles []int
// Width of one tile in pixels.
Width float64
// Z is the draw layer every tile lands on. A floor belongs below the
// entities that stand on it: inside one layer the draw order is by
// baseline, so a tile sharing a layer with an actor draws over it
// whenever the tile sits lower down the screen.
Z int
}
Tilemap describes a grid of tiles to add as entities, one per cell.
type TimeSettings ¶
type TimeSettings struct {
// FullscreenDebounce is the shortest gap, in milliseconds, between two
// fullscreen toggles. Browsers refuse a burst of requests.
FullscreenDebounce float64
// MaxStep caps one frame's elapsed time, in milliseconds. Without it a tab
// left in the background teleports everything when it comes back.
MaxStep float64
// Scale multiplies every frame's elapsed time. Below 1 is slow motion.
Scale float64
// TickRate is how many times a second the simulation runs, apart from the
// frame rate. A higher rate costs more and reacts sooner; a lower one is
// cheaper and coarser.
//
// Two machines simulating one world have to agree on it, so a networked
// game sets it from the server rather than from a saved menu.
TickRate float64
}
TimeSettings decides how fast the world runs.
type WorldSettings ¶
type WorldSettings struct {
// HitBoxMargin shrinks every hit box by this many pixels on all four
// sides, so sprites have to visibly overlap before they collide.
HitBoxMargin float64
// Speed is the base movement speed, in pixels per millisecond.
Speed float64
}
WorldSettings decides how entities move and touch.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
art
command
The art: what PixelLab generated, downloaded by ID and reshaped for the engine.
|
The art: what PixelLab generated, downloaded by ID and reshaped for the engine. |
|
lab
command
The lab: one scene that exists to be measured and tuned.
|
The lab: one scene that exists to be measured and tuned. |
|
serve
command
Command serve hosts the lab: one page and the web tree, under the same security headers and the same cache contract a deployed game gets, and the world the lab plays in, over GET /ws.
|
Command serve hosts the lab: one page and the web tree, under the same security headers and the same cache contract a deployed game gets, and the world the lab plays in, over GET /ws. |
|
internal
|
|
|
lab
Package lab is what the lab's client and its server agree on: the world's size, the art and its sheets, the three skills and their cooldowns, the bouncers and the bounce that moves them, the floor and the edge of the world.
|
Package lab is what the lab's client and its server agree on: the world's size, the art and its sheets, the three skills and their cooldowns, the bouncers and the bounce that moves them, the floor and the edge of the world. |
|
pixellab
Package pixellab reads what PixelLab exports: the layout JSON beside a character's spritesheet, and the metadata JSON beside a tileset's sheet.
|
Package pixellab reads what PixelLab exports: the layout JSON beside a character's spritesheet, and the metadata JSON beside a tileset's sheet. |
|
replica
Package replica keeps a copy of the server's world in an engine: the server's slots mapped to the engine's indices, the server's messages in the order they arrived, and the depth rule that decides how many of them one tick applies.
|
Package replica keeps a copy of the server's world in an engine: the server's slots mapped to the engine's indices, the server's messages in the order they arrived, and the depth rule that decides how many of them one tick applies. |
|
wire
Package wire is the lab's network protocol: the messages a client and the server exchange, and their bytes.
|
Package wire is the lab's network protocol: the messages a client and the server exchange, and their bytes. |
|
ws
Package ws is RFC 6455 for the lab's server: Accept turns an HTTP request into a WebSocket, Dial is the client half for tests and bots, and a Conn moves binary messages over either.
|
Package ws is RFC 6455 for the lab's server: Accept turns an HTTP request into a WebSocket, Dial is the client half for tests and bots, and a Conn moves binary messages over either. |