From 7093b102e3602efff41e063a31dff9e196709266 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 13 Jun 2021 14:53:21 -0700 Subject: [PATCH] Embeddable Doodads In Levels * The Publisher is all hooked up. No native Save File dialogs yet, so uses the dev shell Prompt() to ask for output filename. * Custom-only or builtin doodads too can be stored in the level's file data, at "assets/doodads/*.doodad" * When loading the embedded level in the Editor: it gets its custom doodads out of its file, and you can drag and drop them elsehwere, link them, Play Mode can use them, etc. but they won't appear in the Doodad Dropper if they are not installed in your local doodads directory. * Fleshed out serialization API for the Doodad files: - LoadFromEmbeddable() looks to load a doodad from embeddable file data in addition to the usual places. - Serialize() returns the doodad in bytes, for easy access to embed into level data. - Deserialize() to parse and return from bytes. * When loading a level that references doodads not found in its embedded data or the filesystem: an Alert modal appears listing the missing doodads. The rest of the level loads fine, but the actors referenced by these doodads don't load. --- pkg/balance/numbers.go | 16 +++-- pkg/doodads/fmt_readwrite.go | 41 +++++++++++++ pkg/editor_scene.go | 1 + pkg/editor_ui_doodad.go | 2 +- pkg/editor_ui_popups.go | 32 +++++++--- pkg/level/filesystem.go | 17 +++++- pkg/level/publishing/publishing.go | 94 ++++++++++++++++++++++++++++++ pkg/uix/canvas.go | 4 +- pkg/uix/canvas_actors.go | 13 ++++- pkg/windows/open_level_editor.go | 62 ++++++++++---------- pkg/windows/publish_level.go | 72 +++++++++++------------ 11 files changed, 267 insertions(+), 87 deletions(-) create mode 100644 pkg/level/publishing/publishing.go diff --git a/pkg/balance/numbers.go b/pkg/balance/numbers.go index 2cf541f..4e2e678 100644 --- a/pkg/balance/numbers.go +++ b/pkg/balance/numbers.go @@ -14,15 +14,15 @@ var ( ScrollboxHoz = 256 // horizontal px from window border to start scrol ScrollboxVert = 160 // NEW: set scrollbox bounds by percents - ScrollboxHozPercent float64 = 0.25 + ScrollboxHozPercent float64 = 0.25 ScrollboxVertPercent float64 = 0.40 // Player speeds - PlayerMaxVelocity float64 = 6 - PlayerAcceleration float64 = 0.9 - Gravity float64 = 6 + PlayerMaxVelocity float64 = 6 + PlayerAcceleration float64 = 0.9 + Gravity float64 = 6 GravityAcceleration float64 = 0.2 - SlopeMaxHeight = 8 // max pixel height for player to walk up a slope + SlopeMaxHeight = 8 // max pixel height for player to walk up a slope // Default chunk size for canvases. ChunkSize = 128 @@ -57,8 +57,12 @@ var ( // Level attachment filename for the custom wallpaper. // NOTE: due to hard-coded "assets/wallpapers/" prefix in uix/canvas.go#LoadLevel. - CustomWallpaperFilename = "custom.b64img" + CustomWallpaperFilename = "custom.b64img" CustomWallpaperEmbedPath = "assets/wallpapers/custom.b64img" + + // Publishing: Doodads-embedded-within-levels. + EmbeddedDoodadsBasePath = "assets/doodads/" + EmbeddedWallpaperBasePath = "assets/wallpapers/" ) // Edit Mode Values diff --git a/pkg/doodads/fmt_readwrite.go b/pkg/doodads/fmt_readwrite.go index 31b9d99..ded12ee 100644 --- a/pkg/doodads/fmt_readwrite.go +++ b/pkg/doodads/fmt_readwrite.go @@ -1,12 +1,14 @@ package doodads import ( + "errors" "fmt" "io/ioutil" "runtime" "sort" "strings" + "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/bindata" "git.kirsle.net/apps/doodle/pkg/branding" "git.kirsle.net/apps/doodle/pkg/enum" @@ -16,6 +18,11 @@ import ( "git.kirsle.net/apps/doodle/pkg/wasm" ) +// Errors. +var ( + ErrNotFound = errors.New("file not found") +) + // ListDoodads returns a listing of all available doodads between all locations, // including user doodads. func ListDoodads() ([]string, error) { @@ -106,7 +113,20 @@ func ListBuiltin() ([]string, error) { return result, nil } +// LoadFromEmbeddable reads a doodad file, checking a level's embeddable +// file data in addition to the usual places. +func LoadFromEmbeddable(filename string, fs filesystem.Embeddable) (*Doodad, error) { + if bin, err := fs.GetFile(balance.EmbeddedDoodadsBasePath + filename); err == nil { + log.Debug("doodads.LoadFromEmbeddable: found %s", filename) + return Deserialize(filename, bin) + } + return LoadFile(filename) +} + // LoadFile reads a doodad file from disk, checking a few locations. +// +// It checks for embedded bindata, system-level doodads on the filesystem, +// and then user-owned doodads in their profile folder. func LoadFile(filename string) (*Doodad, error) { if !strings.HasSuffix(filename, enum.DoodadExt) { filename += enum.DoodadExt @@ -177,3 +197,24 @@ func (d *Doodad) WriteFile(filename string) error { return nil } + +// 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 + d.GameVersion = branding.Version + + bin, err := d.ToJSON() + if err != nil { + return []byte{}, err + } + + return bin, nil +} + +// Deserialize loads a doodad from its bytes format. +func Deserialize(filename string, bin []byte) (*Doodad, error) { + return FromJSON(filename, bin) +} diff --git a/pkg/editor_scene.go b/pkg/editor_scene.go index 1e04624..3780f31 100644 --- a/pkg/editor_scene.go +++ b/pkg/editor_scene.go @@ -279,6 +279,7 @@ func (s *EditorScene) LoadLevel(filename string) error { log.Info("Installing %d actors into the drawing", len(level.Actors)) if err := s.UI.Canvas.InstallActors(level.Actors); err != nil { + modal.Alert("This level references some doodads that were not found:\n\n%s", err).WithTitle("Level Errors") return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err) } diff --git a/pkg/editor_ui_doodad.go b/pkg/editor_ui_doodad.go index 01bb662..b356d22 100644 --- a/pkg/editor_ui_doodad.go +++ b/pkg/editor_ui_doodad.go @@ -28,7 +28,7 @@ func (u *EditorUI) startDragActor(doodad *doodads.Doodad, actor *level.Actor) { if doodad == nil { if actor != nil { - obj, err := doodads.LoadFile(actor.Filename) + obj, err := doodads.LoadFromEmbeddable(actor.Filename, u.Scene.Level) if err != nil { log.Error("startDragExistingActor: actor doodad name %s not found: %s", actor.Filename, err) return diff --git a/pkg/editor_ui_popups.go b/pkg/editor_ui_popups.go index 2011f87..4958e86 100644 --- a/pkg/editor_ui_popups.go +++ b/pkg/editor_ui_popups.go @@ -2,9 +2,13 @@ package doodle import ( "fmt" + "os" + "path/filepath" + "strings" "git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/level/publishing" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/modal" "git.kirsle.net/apps/doodle/pkg/windows" @@ -114,13 +118,27 @@ func (u *EditorUI) SetupPopups(d *Doodle) { Engine: d.Engine, Level: scene.Level, - OnPublish: func() { - modal.Alert("Not Yet Implemented") - // filename, err := native.SaveFile("Save your level", "*.level") - // if err != nil { - // modal.Alert(err.Error()) - // } - // log.Info("Write to: %s", filename) + OnPublish: func(includeBuiltins bool) { + log.Debug("OnPublish: include builtins=%+v", includeBuiltins) + cwd, _ := os.Getwd() + d.Prompt(fmt.Sprintf("File name (relative to %s)> ", cwd), func(answer string) { + if answer == "" { + d.Flash("A file name is required to publish this level.") + return + } + + if !strings.HasSuffix(answer, ".level") { + answer += ".level" + } + + answer = filepath.Join(cwd, answer) + log.Debug("call with includeBuiltins=%+v", includeBuiltins) + if _, err := publishing.Publish(scene.Level, answer, includeBuiltins); err != nil { + modal.Alert("Error when publishing the level: %s", err) + return + } + d.Flash("Exported published level to: %s", answer) + }) }, OnCancel: func() { u.publishWindow.Hide() diff --git a/pkg/level/filesystem.go b/pkg/level/filesystem.go index ab30d8c..13683c7 100644 --- a/pkg/level/filesystem.go +++ b/pkg/level/filesystem.go @@ -3,6 +3,7 @@ package level import ( "errors" "sort" + "strings" ) // FileSystem embeds a map of files inside a parent drawing. @@ -63,4 +64,18 @@ func (l *Level) ListFiles() []string { sort.Strings(files) return files -} \ No newline at end of file +} + +// ListFilesAt returns the list of files having a common prefix. +func (l *Level) ListFilesAt(prefix string) []string { + var ( + files = l.ListFiles() + match = []string{} + ) + for _, name := range files { + if strings.HasPrefix(name, prefix) { + match = append(match, name) + } + } + return match +} diff --git a/pkg/level/publishing/publishing.go b/pkg/level/publishing/publishing.go new file mode 100644 index 0000000..fe898a2 --- /dev/null +++ b/pkg/level/publishing/publishing.go @@ -0,0 +1,94 @@ +/* +Package publishing contains functionality for "publishing" a Level, which +involves the writing and reading of custom doodads embedded inside +the levels. + +Free tiers of the game will not read or write embedded doodads into +levels. +*/ +package publishing + +import ( + "fmt" + "sort" + + "git.kirsle.net/apps/doodle/pkg/balance" + "git.kirsle.net/apps/doodle/pkg/doodads" + "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/log" +) + +/* +Level "Publishing" functions, involving the writing and reading of embedded +doodads within level files. +*/ + +// Publish writes a published level file, with embedded doodads included. +func Publish(lvl *level.Level, filename string, includeBuiltins bool) (*level.Level, error) { + // Get and embed the doodads. + builtins, customs := GetUsedDoodadNames(lvl) + if includeBuiltins { + log.Debug("including builtins: %+v", builtins) + customs = append(customs, builtins...) + } + for _, filename := range customs { + log.Debug("Embed filename: %s", filename) + doodad, err := doodads.LoadFromEmbeddable(filename, lvl) + if err != nil { + return nil, fmt.Errorf("couldn't load doodad %s: %s", filename, err) + } + + bin, err := doodad.Serialize() + if err != nil { + return nil, fmt.Errorf("couldn't serialize doodad %s: %s", filename, err) + } + + // Embed it. + lvl.SetFile(balance.EmbeddedDoodadsBasePath+filename, bin) + } + + log.Info("Publish: write file to %s", filename) + err := lvl.WriteFile(filename) + return lvl, err +} + +// GetUsedDoodadNames returns the lists of doodad filenames in use in a level, +// bucketed by built-in or custom user doodads. +func GetUsedDoodadNames(lvl *level.Level) (builtin []string, custom []string) { + // Collect all the doodad names in use in this level. + unique := map[string]interface{}{} + names := []string{} + if lvl != nil { + for _, actor := range lvl.Actors { + if _, ok := unique[actor.Filename]; ok { + continue + } + unique[actor.Filename] = nil + names = append(names, actor.Filename) + } + } + + // Identify which of the doodads are built-ins. + // builtin = []string{} + builtinMap := map[string]interface{}{} + // custom := []string{} + if builtins, err := doodads.ListBuiltin(); err == nil { + for _, filename := range builtins { + if _, ok := unique[filename]; ok { + builtin = append(builtin, filename) + builtinMap[filename] = nil + } + } + } + for _, name := range names { + if _, ok := builtinMap[name]; ok { + continue + } + custom = append(custom, name) + } + + sort.Strings(builtin) + sort.Strings(custom) + + return builtin, custom +} diff --git a/pkg/uix/canvas.go b/pkg/uix/canvas.go index 2255abc..f71cb52 100644 --- a/pkg/uix/canvas.go +++ b/pkg/uix/canvas.go @@ -150,12 +150,12 @@ func (w *Canvas) LoadLevel(e render.Engine, level *level.Level) { w.Load(level.Palette, level.Chunker) // TODO: wallpaper paths - filename := "assets/wallpapers/" + level.Wallpaper + filename := balance.EmbeddedWallpaperBasePath + level.Wallpaper if runtime.GOOS != "js" { // Check if the wallpaper wasn't found. Check bindata and file system. if _, err := filesystem.FindFileEmbedded(filename, level); err != nil { log.Error("LoadLevel: wallpaper %s did not appear to exist, default to notebook.png", filename) - filename = "assets/wallpapers/notebook.png" + filename = balance.EmbeddedWallpaperBasePath + "notebook.png" } } diff --git a/pkg/uix/canvas_actors.go b/pkg/uix/canvas_actors.go index 05892e7..12c1d48 100644 --- a/pkg/uix/canvas_actors.go +++ b/pkg/uix/canvas_actors.go @@ -2,7 +2,7 @@ package uix import ( "errors" - "fmt" + "strings" "git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/level" @@ -14,11 +14,14 @@ import ( // InstallActors adds external Actors to the canvas to be superimposed on top // of the drawing. func (w *Canvas) InstallActors(actors level.ActorMap) error { + var errs []string + w.actors = make([]*Actor, 0) for id, actor := range actors { - doodad, err := doodads.LoadFile(actor.Filename) + doodad, err := doodads.LoadFromEmbeddable(actor.Filename, w.level) if err != nil { - return fmt.Errorf("InstallActors: %s", err) + errs = append(errs, err.Error()) + continue } // Create the "live" Actor to exist in the world, and set its world @@ -28,6 +31,10 @@ func (w *Canvas) InstallActors(actors level.ActorMap) error { w.actors = append(w.actors, liveActor) } + + if len(errs) > 0 { + return errors.New(strings.Join(errs, "\n")) + } return nil } diff --git a/pkg/windows/open_level_editor.go b/pkg/windows/open_level_editor.go index 05c4b48..f2753ec 100644 --- a/pkg/windows/open_level_editor.go +++ b/pkg/windows/open_level_editor.go @@ -189,41 +189,41 @@ func NewOpenLevelEditor(config OpenLevelEditor) *ui.Window { } }(i, dd) } - } - // Browse button for local filesystem. - browseDoodadFrame := ui.NewFrame("Browse Doodad Frame") - frame.Pack(browseDoodadFrame, ui.Pack{ - Side: ui.N, - Expand: true, - FillX: true, - PadY: 1, - }) + // Browse button for local filesystem. + browseDoodadFrame := ui.NewFrame("Browse Doodad Frame") + frame.Pack(browseDoodadFrame, ui.Pack{ + Side: ui.N, + Expand: true, + FillX: true, + PadY: 1, + }) - browseDoodadButton := ui.NewButton("Browse Doodad", ui.NewLabel(ui.Label{ - Text: "Browse...", - Font: balance.MenuFont, - })) - browseDoodadButton.SetStyle(&balance.ButtonPrimary) - browseDoodadFrame.Pack(browseDoodadButton, ui.Pack{ - Side: ui.W, - }) + browseDoodadButton := ui.NewButton("Browse Doodad", ui.NewLabel(ui.Label{ + Text: "Browse...", + Font: balance.MenuFont, + })) + browseDoodadButton.SetStyle(&balance.ButtonPrimary) + browseDoodadFrame.Pack(browseDoodadButton, ui.Pack{ + Side: ui.W, + }) - browseDoodadButton.Handle(ui.Click, func(ed ui.EventData) error { - filename, err := native.OpenFile("Choose a .doodad file", "*.doodad") - if err != nil { - log.Error("Couldn't show file dialog: %s", err) + browseDoodadButton.Handle(ui.Click, func(ed ui.EventData) error { + filename, err := native.OpenFile("Choose a .doodad file", "*.doodad") + if err != nil { + log.Error("Couldn't show file dialog: %s", err) + return nil + } + + if config.LoadForPlay { + config.OnPlayLevel(filename) + } else { + config.OnEditLevel(filename) + } return nil - } - - if config.LoadForPlay { - config.OnPlayLevel(filename) - } else { - config.OnEditLevel(filename) - } - return nil - }) - config.Supervisor.Add(browseDoodadButton) + }) + config.Supervisor.Add(browseDoodadButton) + } /****************** * Confirm/cancel buttons. diff --git a/pkg/windows/publish_level.go b/pkg/windows/publish_level.go index 0de56b2..514e162 100644 --- a/pkg/windows/publish_level.go +++ b/pkg/windows/publish_level.go @@ -3,12 +3,11 @@ package windows import ( "fmt" "math" - "sort" "strings" "git.kirsle.net/apps/doodle/pkg/balance" - "git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/level/publishing" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/go/render" "git.kirsle.net/go/ui" @@ -21,7 +20,7 @@ type Publish struct { Engine render.Engine Level *level.Level - OnPublish func() + OnPublish func(builtinToo bool) OnCancel func() // Private vars. @@ -124,39 +123,40 @@ func NewPublishWindow(cfg Publish) *ui.Window { PadX: 2, }) - // Collect all the doodad names in use in this level. - unique := map[string]interface{}{} - names := []string{} - if cfg.Level != nil { - for _, actor := range cfg.Level.Actors { - if _, ok := unique[actor.Filename]; ok { - continue - } - unique[actor.Filename] = nil - names = append(names, actor.Filename) - } - } + // // Collect all the doodad names in use in this level. + // unique := map[string]interface{}{} + // names := []string{} + // if cfg.Level != nil { + // for _, actor := range cfg.Level.Actors { + // if _, ok := unique[actor.Filename]; ok { + // continue + // } + // unique[actor.Filename] = nil + // names = append(names, actor.Filename) + // } + // } - sort.Strings(names) + // sort.Strings(names) - // Identify which of the doodads are built-ins. - usedBuiltins := []string{} - builtinMap := map[string]interface{}{} - usedCustom := []string{} - if builtins, err := doodads.ListBuiltin(); err == nil { - for _, filename := range builtins { - if _, ok := unique[filename]; ok { - usedBuiltins = append(usedBuiltins, filename) - builtinMap[filename] = nil - } - } - } - for _, name := range names { - if _, ok := builtinMap[name]; ok { - continue - } - usedCustom = append(usedCustom, name) - } + // // Identify which of the doodads are built-ins. + // usedBuiltins := []string{} + // builtinMap := map[string]interface{}{} + // usedCustom := []string{} + // if builtins, err := doodads.ListBuiltin(); err == nil { + // for _, filename := range builtins { + // if _, ok := unique[filename]; ok { + // usedBuiltins = append(usedBuiltins, filename) + // builtinMap[filename] = nil + // } + // } + // } + // for _, name := range names { + // if _, ok := builtinMap[name]; ok { + // continue + // } + // usedCustom = append(usedCustom, name) + // } + usedBuiltins, usedCustom := publishing.GetUsedDoodadNames(cfg.Level) // Helper function to draw the button rows for a set of doodads. mkDoodadRows := func(filenames []string, builtin bool) []*ui.Frame { @@ -201,7 +201,7 @@ func NewPublishWindow(cfg Publish) *ui.Window { builtinRows = []*ui.Frame{} customRows = []*ui.Frame{} ) - if len(names) > 0 { + if len(usedCustom) > 0 { customRows = mkDoodadRows(usedCustom, false) btnRows = append(btnRows, customRows...) } @@ -284,7 +284,7 @@ func NewPublishWindow(cfg Publish) *ui.Window { }{ {"Export Level", true, func() { if cfg.OnPublish != nil { - cfg.OnPublish() + cfg.OnPublish(cfg.includeBuiltins) } }}, {"Close", false, func() {