WIP: MsgPack stubs, Level Filesystem Module

* Add some encoding/decoding functions for binary msgpack format for
  levels and doodads. Currently it writes msgpack files that can be
  decoded and printed by Python (mp2json.py) but it can't re-read from
  the binary format. For now, levels will continue to write in JSON
  format.
* Add filesystem abstraction functions to the balance/ package to search
  multiple paths to find Levels and Doodads, to make way for
  system-level doodads.
This commit is contained in:
Noah 2019-05-05 14:03:20 -07:00
parent d042457365
commit f76ba6fbb7
17 changed files with 611 additions and 49 deletions

View File

@ -4,6 +4,10 @@ Makefile commands for Linux:
* `make setup`: install Go dependencies and set up the build environment
* `make build`: build the Doodle and Doodad binaries to the `bin/` folder.
* `make build-free`: build the shareware binaries to the `bin/` folder. See
Build Tags below.
* `make build-debug`: build a debug binary (not release-mode) to the `bin/`
folder. See Build Tags below.
* `make run`: run a local dev build of Doodle in debug mode
* `make guitest`: run a local dev build in the GUITest scene
* `make test`: run the test suite
@ -17,6 +21,33 @@ Makefile commands for Linux:
* `make docker.fedora`
* `make clean`: clean all build artifacts
## Build Tags
### shareware
> Files ending with `_free.go` are for the shareware release as opposed to
> `_paid.go` for the full version.
Builds the game in the free shareware release mode.
Run `make build-free` to build the shareware binary.
Shareware releases of the game have the following changes compared to the default
(release) mode:
* No access to the Doodad Editor scene in-game (soft toggle)
### developer
> Files ending with `_developer.go` are for the developer build as opposed to
> `_release.go` for the public version.
Developer builds support extra features over the standard release version:
* Ability to write the JSON file format for Levels and Doodads.
Run `make build-debug` to build a developer version of the program.
## Linux
Dependencies are Go, SDL2 and SDL2_ttf:

View File

@ -27,6 +27,13 @@ build-free:
go build $(LDFLAGS) -tags="shareware" -i -o bin/doodle cmd/doodle/main.go
go build $(LDFLAGS) -tags="shareware" -i -o bin/doodad cmd/doodad/main.go
# `make build-debug` to build the binary in developer mode.
.PHONY: build-debug
build-debug:
gofmt -w .
go build $(LDFLAGS) -tags="developer" -i -o bin/doodle cmd/doodle/main.go
go build $(LDFLAGS) -tags="developer" -i -o bin/doodad cmd/doodad/main.go
# `make doodads` to build the doodads from the dev-assets folder.
.PHONY: doodads
doodads:

View File

@ -1,4 +1,52 @@
function main() {
log.Info("Azulian '%s' initialized!", Self.Doodad.Title);
Self.ShowLayer(2);
var playerSpeed = 12;
var gravity = 4;
var Vx = Vy = 0;
var animating = false;
var animStart = animEnd = 0;
var animFrame = animStart;
setInterval(function() {
if (animating) {
if (animFrame < animStart || animFrame > animEnd) {
animFrame = animStart;
}
animFrame++;
if (animFrame === animEnd) {
animFrame = animStart;
}
Self.ShowLayer(animFrame);
} else {
Self.ShowLayer(animStart);
}
}, 100);
Events.OnKeypress(function(ev) {
Vx = 0;
Vy = 0;
if (ev.Right.Now) {
animStart = 2;
animEnd = animStart+4;
animating = true;
Vx = playerSpeed;
} else if (ev.Left.Now) {
animStart = 6;
animEnd = animStart+4;
animating = true;
Vx = -playerSpeed;
} else {
animating = false;
}
if (!Self.Grounded()) {
Vy += gravity;
}
// Self.SetVelocity(Point(Vx, Vy));
})
}

View File

@ -7,6 +7,8 @@ import (
"image/color"
"regexp"
"strconv"
"github.com/vmihailenco/msgpack"
)
var (
@ -155,6 +157,52 @@ func (c *Color) UnmarshalJSON(b []byte) error {
return nil
}
func (c Color) EncodeMsgpack(enc *msgpack.Encoder) error {
return enc.EncodeString(fmt.Sprintf(
`"#%02x%02x%02x"`,
c.Red, c.Green, c.Blue,
))
}
func (c Color) DecodeMsgpack(dec *msgpack.Decoder) error {
hex, err := dec.DecodeString()
if err != nil {
return fmt.Errorf("Color.DecodeMsgpack: %s", err)
}
parsed, err := HexColor(hex)
if err != nil {
return fmt.Errorf("Color.DecodeMsgpack: HexColor: %s", err)
}
c.Red = parsed.Red
c.Blue = parsed.Blue
c.Green = parsed.Green
c.Alpha = parsed.Alpha
return nil
}
// // MarshalMsgpack serializes the Color for msgpack.
// func (c Color) MarshalMsgpack() ([]byte, error) {
// data := []uint8{
// c.Red, c.Green, c.Blue, c.Alpha,
// }
// return msgpack.Marshal(data)
// }
//
// // UnmarshalMsgpack decodes a Color from msgpack format.
// func (c *Color) UnmarshalMsgpack(b []byte) error {
// var data []uint8
// if err := msgpack.Unmarshal(data, b); err != nil {
// return err
// }
// c.Red = 255
// c.Green = data[1]
// c.Blue = data[2]
// c.Alpha = data[3]
// return nil
// }
// Add a relative color value to the color.
func (c Color) Add(r, g, b, a int) Color {
var (

22
mp2json.py Normal file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""mp2json: convert a msgpack binary file into JSON for debugging."""
import msgpack
import json
import sys
if len(sys.argv) < 2:
print("Usage: mp2json <filename.level>")
with open(sys.argv[1], 'rb') as fh:
header = fh.read(8)
magic = header[:6].decode("utf-8")
if magic != "DOODLE":
print("input file doesn't appear to be a doodle drawing binary")
sys.exit(1)
reader = msgpack.Unpacker(fh, raw=False, max_buffer_size=10*1024*1024)
for o in reader:
print(o)
print(json.dumps(o, indent=2))

View File

@ -30,6 +30,9 @@ var (
// Put a border around all Canvas widgets.
DebugCanvasBorder = render.Invisible
DebugCanvasLabel = false // Tag the canvas with a label.
// Pretty-print JSON files when writing.
JSONIndent = false
)
func init() {

118
pkg/balance/filesystem.go Normal file
View File

@ -0,0 +1,118 @@
package balance
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/userdir"
)
// Binary file format headers for Levels and Doodads.
//
// The header is 8 bytes long: "DOODLE" + file format version + file type number.
const (
BinMagic = "DOODLE"
BinVersion uint8 = 1 // version of the file format we support
BinLevelType uint8 = 1
BinDoodadType uint8 = 2
)
// MakeHeader creates the binary file header.
func MakeHeader(filetype uint8) []byte {
header := make([]byte, len(BinMagic)+2)
for i := 0; i < len(BinMagic); i++ {
header[i] = BinMagic[i]
}
header[len(header)-2] = BinVersion
header[len(header)-1] = filetype
return header
}
// ReadHeader reads and verifies a header from a filehandle.
func ReadHeader(filetype uint8, fh io.Reader) error {
header := make([]byte, len(BinMagic)+2)
_, err := fh.Read(header)
if err != nil {
return fmt.Errorf("ReadHeader: %s", err)
}
if string(header[:len(BinMagic)]) != BinMagic {
return errors.New("not a doodle drawing (no magic number in header)")
}
// Verify the file format version and type.
var (
fileVersion = header[len(header)-2]
fileType = header[len(header)-1]
)
if fileVersion == 0 || fileVersion > BinVersion {
return errors.New("binary format was created using a newer version of the game")
} else if fileType != filetype {
return errors.New("drawing type is not the type we expected")
}
return nil
}
/*
FindFile looks for a file (level or doodad) in a few places.
The filename should already have a ".level" or ".doodad" file extension. If
neither is given, the exact filename will be searched in all places.
1. Check in the files built into the program binary.
2. Check for system files in the binary's assets/ folder.
3. Check the user folders.
Returns the file path and an error if not found anywhere.
*/
func FindFile(filename string) (string, error) {
var filetype string
// Any hint on what type of file we're looking for?
if strings.HasSuffix(filename, enum.LevelExt) {
filetype = enum.LevelExt
} else if strings.HasSuffix(filename, enum.DoodadExt) {
filetype = enum.DoodadExt
}
// Search level directories.
if filetype == enum.LevelExt || filetype == "" {
// system levels
candidate := filepath.Join(".", "assets", "levels", filename)
if _, err := os.Stat(candidate); !os.IsNotExist(err) {
return candidate, nil
}
// user levels
candidate = userdir.LevelPath(filename)
if _, err := os.Stat(candidate); !os.IsNotExist(err) {
return candidate, nil
}
}
// Search doodad directories.
if filetype == enum.DoodadExt || filetype == "" {
// system doodads
candidate := filepath.Join(".", "assets", "doodads", filename)
if _, err := os.Stat(candidate); !os.IsNotExist(err) {
return candidate, nil
}
// user doodads
candidate = userdir.DoodadPath(filename)
if _, err := os.Stat(candidate); !os.IsNotExist(err) {
return candidate, nil
}
}
return "", errors.New("file not found")
}

View File

@ -3,7 +3,6 @@ package doodle
import (
"errors"
"fmt"
"io/ioutil"
"os"
"strings"
@ -171,7 +170,7 @@ func (s *EditorScene) Draw(d *Doodle) error {
func (s *EditorScene) LoadLevel(filename string) error {
s.filename = filename
level, err := level.LoadJSON(filename)
level, err := level.LoadFile(filename)
fmt.Printf("%+v\n", level)
if err != nil {
return fmt.Errorf("EditorScene.LoadLevel(%s): %s", filename, err)
@ -213,20 +212,7 @@ func (s *EditorScene) SaveLevel(filename string) error {
m.Palette = s.UI.Canvas.Palette
m.Chunker = s.UI.Canvas.Chunker()
json, err := m.ToJSON()
if err != nil {
return fmt.Errorf("SaveLevel error: %s", err)
}
// Save it to their profile directory.
filename = userdir.LevelPath(filename)
log.Info("Write Level: %s", filename)
err = ioutil.WriteFile(filename, json, 0644)
if err != nil {
return fmt.Errorf("Create map file error: %s", err)
}
return nil
return m.WriteFile(filename)
}
// LoadDoodad loads a doodad from disk.

View File

@ -12,6 +12,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/userdir"
"github.com/satori/go.uuid"
"github.com/vmihailenco/msgpack"
"golang.org/x/image/bmp"
)
@ -41,8 +42,9 @@ type Chunk struct {
// JSONChunk holds a lightweight (interface-free) copy of the Chunk for
// unmarshalling JSON files from disk.
type JSONChunk struct {
Type int `json:"type"`
Data json.RawMessage `json:"data"`
Type int `json:"type" msgpack:"0"`
Data json.RawMessage `json:"data" msgpack:"-"`
BinData interface{} `json:"-" msgpack:"1"`
}
// Accessor provides a high-level API to interact with absolute pixel coordinates
@ -57,6 +59,9 @@ type Accessor interface {
Len() int
MarshalJSON() ([]byte, error)
UnmarshalJSON([]byte) error
// MarshalMsgpack() ([]byte, error)
// UnmarshalMsgpack([]byte) error
// Serialize() interface{}
}
// NewChunk creates a new chunk.
@ -277,3 +282,66 @@ func (c *Chunk) UnmarshalJSON(b []byte) error {
return fmt.Errorf("Chunk.UnmarshalJSON: unsupported chunk type '%d'", c.Type)
}
}
func (c *Chunk) EncodeMsgpack(enc *msgpack.Encoder) error {
data := c.Accessor
generic := &JSONChunk{
Type: c.Type,
BinData: data,
}
return enc.Encode(generic)
}
func (c *Chunk) DecodeMsgpack(dec *msgpack.Decoder) error {
generic := &JSONChunk{}
err := dec.Decode(generic)
if err != nil {
return fmt.Errorf("Chunk.DecodeMsgpack: %s", err)
}
switch c.Type {
case MapType:
c.Accessor = generic.BinData.(MapAccessor)
default:
return fmt.Errorf("Chunk.DecodeMsgpack: unsupported chunk type '%d'", c.Type)
}
return nil
}
// // MarshalMsgpack writes the chunk to msgpack format.
// func (c *Chunk) MarshalMsgpack() ([]byte, error) {
// // data, err := c.Accessor.MarshalMsgpack()
// // if err != nil {
// // return []byte{}, err
// // }
// data := c.Accessor
//
// generic := &JSONChunk{
// Type: c.Type,
// BinData: data,
// }
// b, err := msgpack.Marshal(generic)
// return b, err
// }
//
// // UnmarshalMsgpack loads the chunk from msgpack format.
// func (c *Chunk) UnmarshalMsgpack(b []byte) error {
// // Parse it generically so we can hand off the inner "data" object to the
// // right accessor for unmarshalling.
// generic := &JSONChunk{}
// err := msgpack.Unmarshal(b, generic)
// if err != nil {
// return fmt.Errorf("Chunk.UnmarshalMsgpack: failed to unmarshal into generic JSONChunk type: %s", err)
// }
//
// switch c.Type {
// case MapType:
// c.Accessor = NewMapAccessor()
// return c.Accessor.UnmarshalMsgpack(generic.Data)
// default:
// return fmt.Errorf("Chunk.UnmarshalMsgpack: unsupported chunk type '%d'", c.Type)
// }
// }

View File

@ -6,6 +6,7 @@ import (
"fmt"
"git.kirsle.net/apps/doodle/lib/render"
"github.com/vmihailenco/msgpack"
)
// MapAccessor implements a chunk accessor by using a map of points to their
@ -127,3 +128,65 @@ func (a MapAccessor) UnmarshalJSON(b []byte) error {
return nil
}
// // MarshalMsgpack serializes for msgpack.
// func (a MapAccessor) MarshalMsgpack() ([]byte, error) {
// dict := map[string]int{}
// for point, sw := range a {
// dict[point.String()] = sw.Index()
// }
// return msgpack.Marshal(dict)
// }
//
// // Serialize converts the chunk accessor to a map for serialization.
// func (a MapAccessor) Serialize() interface{} {
// dict := map[string]int{}
// for point, sw := range a {
// dict[point.String()] = sw.Index()
// }
// return dict
// }
//
// // UnmarshalMsgpack decodes from msgpack format.
// func (a MapAccessor) UnmarshalMsgpack(b []byte) error {
// var dict map[string]int
// err := msgpack.Unmarshal(b, &dict)
// if err != nil {
// return err
// }
//
// for coord, index := range dict {
// point, err := render.ParsePoint(coord)
// if err != nil {
// return fmt.Errorf("MapAccessor.UnmarshalJSON: %s", err)
// }
// a[point] = NewSparseSwatch(index)
// }
//
// return nil
// }
func (a MapAccessor) EncodeMsgpack(enc *msgpack.Encoder) error {
dict := map[string]int{}
for point, sw := range a {
dict[point.String()] = sw.Index()
}
return enc.Encode(dict)
}
func (a MapAccessor) DecodeMsgpack(dec *msgpack.Decoder) error {
v, err := dec.DecodeMap()
if err != nil {
return fmt.Errorf("MapAccessor.DecodeMsgpack: %s", err)
}
dict := v.(map[string]int)
for coord, index := range dict {
point, err := render.ParsePoint(coord)
if err != nil {
return fmt.Errorf("MapAccessor.UnmarshalJSON: %s", err)
}
a[point] = NewSparseSwatch(index)
}
return nil
}

View File

@ -6,6 +6,7 @@ import (
"math"
"git.kirsle.net/apps/doodle/lib/render"
"github.com/vmihailenco/msgpack"
)
// Chunker is the data structure that manages the chunks of a level, and
@ -240,3 +241,14 @@ func (c ChunkMap) MarshalJSON() ([]byte, error) {
out, err := json.Marshal(dict)
return out, err
}
// MarshalMsgpack to convert the chunk map to binary.
func (c ChunkMap) MarshalMsgpack() ([]byte, error) {
dict := map[string]*Chunk{}
for point, chunk := range c {
dict[point.String()] = chunk
}
out, err := msgpack.Marshal(dict)
return out, err
}

71
pkg/level/fmt_binary.go Normal file
View File

@ -0,0 +1,71 @@
package level
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"git.kirsle.net/apps/doodle/pkg/balance"
"github.com/vmihailenco/msgpack"
)
// ToBinary serializes the level to binary format.
func (m *Level) ToBinary() ([]byte, error) {
header := balance.MakeHeader(balance.BinLevelType)
out := bytes.NewBuffer(header)
encoder := msgpack.NewEncoder(out)
err := encoder.Encode(m)
return out.Bytes(), err
}
// WriteBinary writes a level to binary format on disk.
func (m *Level) WriteBinary(filename string) error {
bin, err := m.ToBinary()
if err != nil {
return fmt.Errorf("Level.WriteBinary: encode error: %s", err)
}
err = ioutil.WriteFile(filename, bin, 0755)
if err != nil {
return fmt.Errorf("Level.WriteBinary: WriteFile error: %s", err)
}
return nil
}
// LoadBinary loads a map from binary file on disk.
func LoadBinary(filename string) (*Level, error) {
fh, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fh.Close()
// Read and verify the file header from the binary format.
err = balance.ReadHeader(balance.BinLevelType, fh)
if err != nil {
return nil, err
}
// Decode the file from disk.
m := New()
decoder := msgpack.NewDecoder(fh)
err = decoder.Decode(&m)
if err != nil {
return m, fmt.Errorf("level.LoadBinary: decode error: %s", err)
}
// Fill in defaults.
if m.Wallpaper == "" {
m.Wallpaper = DefaultWallpaper
}
// Inflate the chunk metadata to map the pixels to their palette indexes.
m.Chunker.Inflate(m.Palette)
m.Actors.Inflate()
// Inflate the private instance values.
m.Palette.Inflate()
return m, err
}

View File

@ -6,32 +6,21 @@ import (
"fmt"
"io/ioutil"
"os"
"git.kirsle.net/apps/doodle/pkg/balance"
)
// ToJSON serializes the level as JSON.
func (m *Level) ToJSON() ([]byte, error) {
out := bytes.NewBuffer([]byte{})
encoder := json.NewEncoder(out)
encoder.SetIndent("", "\t")
if balance.JSONIndent {
encoder.SetIndent("", "\t")
}
err := encoder.Encode(m)
return out.Bytes(), err
}
// WriteJSON writes a level to JSON on disk.
func (m *Level) WriteJSON(filename string) error {
json, err := m.ToJSON()
if err != nil {
return fmt.Errorf("Level.WriteJSON: JSON encode error: %s", err)
}
err = ioutil.WriteFile(filename, json, 0755)
if err != nil {
return fmt.Errorf("Level.WriteJSON: WriteFile error: %s", err)
}
return nil
}
// LoadJSON loads a map from JSON file.
func LoadJSON(filename string) (*Level, error) {
fh, err := os.Open(filename)
@ -61,3 +50,18 @@ func LoadJSON(filename string) (*Level, error) {
m.Palette.Inflate()
return m, err
}
// WriteJSON writes a level to JSON on disk.
func (m *Level) WriteJSON(filename string) error {
json, err := m.ToJSON()
if err != nil {
return fmt.Errorf("Level.WriteJSON: JSON encode error: %s", err)
}
err = ioutil.WriteFile(filename, json, 0755)
if err != nil {
return fmt.Errorf("Level.WriteJSON: WriteFile error: %s", err)
}
return nil
}

View File

@ -0,0 +1,65 @@
package level
import (
"errors"
"fmt"
"io/ioutil"
"strings"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/userdir"
)
// LoadFile reads a level file from disk, checking a few locations.
func LoadFile(filename string) (*Level, error) {
if !strings.HasSuffix(filename, enum.LevelExt) {
filename += enum.LevelExt
}
// Search the system and user paths for this level.
filename, err := balance.FindFile(filename)
if err != nil {
return nil, err
}
// Try the binary format.
if level, err := LoadBinary(filename); err == nil {
return level, nil
} else {
log.Warn(err.Error())
}
// Then the JSON format.
if level, err := LoadJSON(filename); err == nil {
return level, nil
} else {
log.Warn(err.Error())
}
return nil, errors.New("invalid file type")
}
// WriteFile saves a level to disk in the user's config directory.
func (m *Level) WriteFile(filename string) error {
if !strings.HasSuffix(filename, enum.LevelExt) {
filename += enum.LevelExt
}
// bin, err := m.ToBinary()
bin, err := m.ToJSON()
if err != nil {
return err
}
// Save it to their profile directory.
filename = userdir.LevelPath(filename)
log.Info("Write Level: %s", filename)
err = ioutil.WriteFile(filename, bin, 0644)
if err != nil {
return fmt.Errorf("level.WriteFile: %s", err)
}
return nil
}

View File

@ -16,36 +16,36 @@ var (
// Base provides the common struct keys that are shared between Levels and
// Doodads.
type Base struct {
Version int `json:"version"` // File format version spec.
GameVersion string `json:"gameVersion"` // Game version that created the level.
Title string `json:"title"`
Author string `json:"author"`
Version int `json:"version" msgpack:"0"` // File format version spec.
GameVersion string `json:"gameVersion" msgpack:"1"` // Game version that created the level.
Title string `json:"title" msgpack:"2"`
Author string `json:"author" msgpack:"3"`
// Every drawing type is able to embed other files inside of itself.
Files FileSystem `json:"files"`
Files FileSystem `json:"files" msgpack:"4"`
}
// Level is the container format for Doodle map drawings.
type Level struct {
Base
Password string `json:"passwd"`
Locked bool `json:"locked"`
Password string `json:"passwd" msgpack:"10"`
Locked bool `json:"locked" msgpack:"11"`
// Chunked pixel data.
Chunker *Chunker `json:"chunks"`
Chunker *Chunker `json:"chunks" msgpack:"12"`
// The Palette holds the unique "colors" used in this map file, and their
// properties (solid, fire, slippery, etc.)
Palette *Palette `json:"palette"`
Palette *Palette `json:"palette" msgpack:"13"`
// Page boundaries and wallpaper settings.
PageType PageType `json:"pageType"`
MaxWidth int64 `json:"boundedWidth"` // only if bounded or bordered
MaxHeight int64 `json:"boundedHeight"`
Wallpaper string `json:"wallpaper"`
PageType PageType `json:"pageType" msgpack:"14"`
MaxWidth int64 `json:"boundedWidth" msgpack:"15"` // only if bounded or bordered
MaxHeight int64 `json:"boundedHeight" msgpack:"16"`
Wallpaper string `json:"wallpaper" msgpack:"17"`
// Actors keep a list of the doodad instances in this map.
Actors ActorMap `json:"actors"`
Actors ActorMap `json:"actors" msgpack:"18"`
}
// New creates a blank level object with all its members initialized.

View File

@ -198,6 +198,11 @@ func (s *PlayScene) movePlayer(ev *events.State) {
}
s.Player.SetVelocity(velocity)
// TODO: invoke the player OnKeypress for animation testing
// if velocity != render.Origin {
s.scripting.To(s.Player.ID()).Events.RunKeypress(ev)
// }
}
// Drawing returns the private world drawing, for debugging with the console.

View File

@ -1,6 +1,7 @@
package scripting
import (
"git.kirsle.net/apps/doodle/lib/events"
"github.com/robertkrimen/otto"
)
@ -36,6 +37,16 @@ func (e *Events) RunCollide() error {
return e.run(CollideEvent)
}
// OnKeypress fires when another actor collides with yours.
func (e *Events) OnKeypress(call otto.FunctionCall) otto.Value {
return e.register(KeypressEvent, call.Argument(0))
}
// RunKeypress invokes the OnCollide handler function.
func (e *Events) RunKeypress(ev *events.State) error {
return e.run(KeypressEvent, ev)
}
// register a named event.
func (e *Events) register(name string, callback otto.Value) otto.Value {
if !callback.IsFunction() {