From 8603c43c58bd7606fe3d943ee408a98f9774ff74 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 13 Jul 2021 19:23:09 -0700 Subject: [PATCH] Gzip Compression for Levels and Doodads * Levels and Doodad files will be written in gzip-compressed JSON format * `boolProp compress-drawings false` to disable compression and save as classic JSON format directly * The game can still read uncompressed JSON files The file size savings on some built-in assets: * Tutorial 2.level: 2.2M -> 414K (82% smaller) * warp-door-orange.doodad: 105K -> 17K (84% smaller) --- pkg/balance/boolprops.go | 4 ++ pkg/balance/numbers.go | 3 ++ pkg/doodads/fmt_gzip.go | 43 ++++++++++++++++ pkg/doodads/fmt_readwrite.go | 1 - pkg/doodads/json.go | 51 ++++++++++++------- pkg/editor_scene.go | 7 ++- pkg/level/fmt_json.go | 99 ++++++++++++++++++++++++++---------- 7 files changed, 161 insertions(+), 47 deletions(-) create mode 100644 pkg/doodads/fmt_gzip.go diff --git a/pkg/balance/boolprops.go b/pkg/balance/boolprops.go index e1b8cf3..e278717 100644 --- a/pkg/balance/boolprops.go +++ b/pkg/balance/boolprops.go @@ -41,6 +41,10 @@ var Boolprops = map[string]Boolprop{ Get: func() bool { return usercfg.Current.HorizontalToolbars }, Set: func(v bool) { usercfg.Current.HorizontalToolbars = v }, }, + "compress-drawings": { + Get: func() bool { return CompressDrawings }, + Set: func(v bool) { CompressDrawings = v }, + }, } // GetBoolProp reads the current value of a boolProp. diff --git a/pkg/balance/numbers.go b/pkg/balance/numbers.go index caf7291..d269a78 100644 --- a/pkg/balance/numbers.go +++ b/pkg/balance/numbers.go @@ -63,6 +63,9 @@ var ( // Publishing: Doodads-embedded-within-levels. EmbeddedDoodadsBasePath = "assets/doodads/" EmbeddedWallpaperBasePath = "assets/wallpapers/" + + // File formats: save new levels and doodads gzip compressed + CompressDrawings = true ) // Edit Mode Values diff --git a/pkg/doodads/fmt_gzip.go b/pkg/doodads/fmt_gzip.go new file mode 100644 index 0000000..7a596e0 --- /dev/null +++ b/pkg/doodads/fmt_gzip.go @@ -0,0 +1,43 @@ +package doodads + +import ( + "bytes" + "compress/gzip" + "encoding/json" +) + +// ToGzip serializes the doodad as gzip compressed JSON. +func (d *Doodad) ToGzip() ([]byte, error) { + var ( + handle = bytes.NewBuffer([]byte{}) + zipper = gzip.NewWriter(handle) + encoder = json.NewEncoder(zipper) + ) + if err := encoder.Encode(d); err != nil { + return nil, err + } + + err := zipper.Close() + return handle.Bytes(), err +} + +// FromGzip deserializes a gzip compressed doodad JSON. +func FromGzip(data []byte) (*Doodad, error) { + // This function works, do not touch. + var ( + level = &Doodad{} + buf = bytes.NewBuffer(data) + reader *gzip.Reader + decoder *json.Decoder + ) + + reader, err := gzip.NewReader(buf) + if err != nil { + return nil, err + } + + decoder = json.NewDecoder(reader) + decoder.Decode(level) + + return level, nil +} diff --git a/pkg/doodads/fmt_readwrite.go b/pkg/doodads/fmt_readwrite.go index db723bd..6af92bc 100644 --- a/pkg/doodads/fmt_readwrite.go +++ b/pkg/doodads/fmt_readwrite.go @@ -204,7 +204,6 @@ func (d *Doodad) WriteFile(filename string) error { // Serialize encodes a doodad to bytes and returns them, instead // of writing to a file. -// WriteFile saves a doodad to disk in the user's config directory. func (d *Doodad) Serialize() ([]byte, error) { // Set the version information. d.Version = 1 diff --git a/pkg/doodads/json.go b/pkg/doodads/json.go index 85e086e..e64dd2f 100644 --- a/pkg/doodads/json.go +++ b/pkg/doodads/json.go @@ -5,14 +5,23 @@ import ( "encoding/json" "fmt" "io/ioutil" - "os" "path/filepath" + "git.kirsle.net/apps/doodle/pkg/balance" + "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/usercfg" ) -// ToJSON serializes the doodad as JSON. +// ToJSON serializes the doodad as JSON (gzip supported). +// +// If balance.CompressLevels=true the doodad will be gzip compressed +// and the return value is gz bytes and not the raw JSON. func (d *Doodad) ToJSON() ([]byte, error) { + // Gzip compressing? + if balance.CompressDrawings { + return d.ToGzip() + } + out := bytes.NewBuffer([]byte{}) encoder := json.NewEncoder(out) if usercfg.Current.JSONIndent { @@ -22,16 +31,32 @@ func (d *Doodad) ToJSON() ([]byte, error) { return out.Bytes(), err } -// FromJSON loads a doodad from JSON string. +// FromJSON loads a doodad from JSON string (gzip supported). func FromJSON(filename string, data []byte) (*Doodad, error) { var doodad = &Doodad{} - err := json.Unmarshal(data, doodad) + + // Inspect the headers of the file to see how it was encoded. + if len(data) > 0 && data[0] == '{' { + // Looks standard JSON. + err := json.Unmarshal(data, doodad) + if err != nil { + return nil, err + } + } else if len(data) > 1 && data[0] == 0x1f && data[1] == 0x8b { + // Gzip compressed. `1F8B` is gzip magic number. + log.Debug("Decompress doodad %s", filename) + if gzd, err := FromGzip(data); err != nil { + return nil, err + } else { + doodad = gzd + } + } // Inflate the chunk metadata to map the pixels to their palette indexes. doodad.Filename = filepath.Base(filename) doodad.Inflate() - return doodad, err + return doodad, nil } // WriteJSON writes a Doodad to JSON on disk. @@ -52,22 +77,10 @@ func (d *Doodad) WriteJSON(filename string) error { // LoadJSON loads a map from JSON file. func LoadJSON(filename string) (*Doodad, error) { - fh, err := os.Open(filename) + data, err := ioutil.ReadFile(filename) if err != nil { return nil, err } - defer fh.Close() - // Decode the JSON file from disk. - d := New(0) - decoder := json.NewDecoder(fh) - err = decoder.Decode(&d) - if err != nil { - return d, fmt.Errorf("doodad.LoadJSON: JSON decode error: %s", err) - } - - // Inflate the chunk metadata to map the pixels to their palette indexes. - d.Filename = filepath.Base(filename) - d.Inflate() - return d, err + return FromJSON(filename, data) } diff --git a/pkg/editor_scene.go b/pkg/editor_scene.go index 6d663f3..9d7d989 100644 --- a/pkg/editor_scene.go +++ b/pkg/editor_scene.go @@ -182,9 +182,14 @@ func (s *EditorScene) ConfirmUnload(fn func()) { func (s *EditorScene) Loop(d *Doodle, ev *event.State) error { // Update debug overlay values. *s.debTool = s.UI.Canvas.Tool.String() - *s.debSwatch = s.UI.Canvas.Palette.ActiveSwatch.Name + *s.debSwatch = "???" *s.debWorldIndex = s.UI.Canvas.WorldIndexAt(s.UI.cursor).String() + // Safely... + if s.UI.Canvas.Palette != nil && s.UI.Canvas.Palette.ActiveSwatch != nil { + *s.debSwatch = s.UI.Canvas.Palette.ActiveSwatch.Name + } + // Has the window been resized? if ev.WindowResized { w, h := d.Engine.WindowSize() diff --git a/pkg/level/fmt_json.go b/pkg/level/fmt_json.go index 70bf770..2474a0b 100644 --- a/pkg/level/fmt_json.go +++ b/pkg/level/fmt_json.go @@ -2,18 +2,39 @@ package level import ( "bytes" + "compress/gzip" "encoding/json" + "errors" "fmt" "io/ioutil" - "os" + "git.kirsle.net/apps/doodle/pkg/balance" + "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/usercfg" ) -// FromJSON loads a level from JSON string. +// FromJSON loads a level from JSON string (gzip supported). func FromJSON(filename string, data []byte) (*Level, error) { var m = New() - err := json.Unmarshal(data, m) + + // Inspect if this file is JSON or gzip compressed. + if len(data) > 0 && data[0] == '{' { + // Looks standard JSON. + err := json.Unmarshal(data, m) + if err != nil { + return nil, err + } + } else if len(data) > 1 && data[0] == 0x1f && data[1] == 0x8b { + // Gzip compressed. `1F8B` is gzip magic number. + log.Debug("Decompress level %s", filename) + if gzmap, err := FromGzip(data); err != nil { + return nil, err + } else { + m = gzmap + } + } else { + return nil, errors.New("invalid file format") + } // Fill in defaults. if m.Wallpaper == "" { @@ -27,11 +48,21 @@ func FromJSON(filename string, data []byte) (*Level, error) { // Inflate the private instance values. m.Palette.Inflate() - return m, err + return m, nil } -// ToJSON serializes the level as JSON. +// ToJSON serializes the level as JSON (gzip supported). +// +// Notice about gzip: if the pkg/balance.CompressLevels boolean is true, this +// function will apply gzip compression before returning the byte string. +// This gzip-compressed level can be read back by any functions that say +// "gzip supported" in their descriptions. func (m *Level) ToJSON() ([]byte, error) { + // Gzip compressing? + if balance.CompressDrawings { + return m.ToGzip() + } + out := bytes.NewBuffer([]byte{}) encoder := json.NewEncoder(out) if usercfg.Current.JSONIndent { @@ -41,34 +72,50 @@ func (m *Level) ToJSON() ([]byte, error) { return out.Bytes(), err } -// LoadJSON loads a map from JSON file. -func LoadJSON(filename string) (*Level, error) { - fh, err := os.Open(filename) +// ToGzip serializes the level as gzip compressed JSON. +func (m *Level) ToGzip() ([]byte, error) { + var ( + handle = bytes.NewBuffer([]byte{}) + zipper = gzip.NewWriter(handle) + encoder = json.NewEncoder(zipper) + ) + if err := encoder.Encode(m); err != nil { + return nil, err + } + + err := zipper.Close() + return handle.Bytes(), err +} + +// FromGzip deserializes a gzip compressed level JSON. +func FromGzip(data []byte) (*Level, error) { + // This function works, do not touch. + var ( + level = New() + buf = bytes.NewBuffer(data) + reader *gzip.Reader + decoder *json.Decoder + ) + + reader, err := gzip.NewReader(buf) if err != nil { return nil, err } - defer fh.Close() - // Decode the JSON file from disk. - m := New() - decoder := json.NewDecoder(fh) - err = decoder.Decode(&m) + decoder = json.NewDecoder(reader) + decoder.Decode(level) + + return level, nil +} + +// LoadJSON loads a map from JSON file (gzip supported). +func LoadJSON(filename string) (*Level, error) { + data, err := ioutil.ReadFile(filename) if err != nil { - return m, fmt.Errorf("level.LoadJSON: JSON decode error: %s", err) + return nil, 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 + return FromJSON(filename, data) } // WriteJSON writes a level to JSON on disk.