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.
This commit is contained in:
Noah 2021-06-13 14:53:21 -07:00
parent d9bca2152a
commit 7093b102e3
11 changed files with 267 additions and 87 deletions

View File

@ -14,15 +14,15 @@ var (
ScrollboxHoz = 256 // horizontal px from window border to start scrol ScrollboxHoz = 256 // horizontal px from window border to start scrol
ScrollboxVert = 160 ScrollboxVert = 160
// NEW: set scrollbox bounds by percents // NEW: set scrollbox bounds by percents
ScrollboxHozPercent float64 = 0.25 ScrollboxHozPercent float64 = 0.25
ScrollboxVertPercent float64 = 0.40 ScrollboxVertPercent float64 = 0.40
// Player speeds // Player speeds
PlayerMaxVelocity float64 = 6 PlayerMaxVelocity float64 = 6
PlayerAcceleration float64 = 0.9 PlayerAcceleration float64 = 0.9
Gravity float64 = 6 Gravity float64 = 6
GravityAcceleration float64 = 0.2 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. // Default chunk size for canvases.
ChunkSize = 128 ChunkSize = 128
@ -57,8 +57,12 @@ var (
// Level attachment filename for the custom wallpaper. // Level attachment filename for the custom wallpaper.
// NOTE: due to hard-coded "assets/wallpapers/" prefix in uix/canvas.go#LoadLevel. // NOTE: due to hard-coded "assets/wallpapers/" prefix in uix/canvas.go#LoadLevel.
CustomWallpaperFilename = "custom.b64img" CustomWallpaperFilename = "custom.b64img"
CustomWallpaperEmbedPath = "assets/wallpapers/custom.b64img" CustomWallpaperEmbedPath = "assets/wallpapers/custom.b64img"
// Publishing: Doodads-embedded-within-levels.
EmbeddedDoodadsBasePath = "assets/doodads/"
EmbeddedWallpaperBasePath = "assets/wallpapers/"
) )
// Edit Mode Values // Edit Mode Values

View File

@ -1,12 +1,14 @@
package doodads package doodads
import ( import (
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"runtime" "runtime"
"sort" "sort"
"strings" "strings"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/bindata" "git.kirsle.net/apps/doodle/pkg/bindata"
"git.kirsle.net/apps/doodle/pkg/branding" "git.kirsle.net/apps/doodle/pkg/branding"
"git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/enum"
@ -16,6 +18,11 @@ import (
"git.kirsle.net/apps/doodle/pkg/wasm" "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, // ListDoodads returns a listing of all available doodads between all locations,
// including user doodads. // including user doodads.
func ListDoodads() ([]string, error) { func ListDoodads() ([]string, error) {
@ -106,7 +113,20 @@ func ListBuiltin() ([]string, error) {
return result, nil 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. // 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) { func LoadFile(filename string) (*Doodad, error) {
if !strings.HasSuffix(filename, enum.DoodadExt) { if !strings.HasSuffix(filename, enum.DoodadExt) {
filename += enum.DoodadExt filename += enum.DoodadExt
@ -177,3 +197,24 @@ func (d *Doodad) WriteFile(filename string) error {
return nil 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)
}

View File

@ -279,6 +279,7 @@ func (s *EditorScene) LoadLevel(filename string) error {
log.Info("Installing %d actors into the drawing", len(level.Actors)) log.Info("Installing %d actors into the drawing", len(level.Actors))
if err := s.UI.Canvas.InstallActors(level.Actors); err != nil { 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) return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err)
} }

View File

@ -28,7 +28,7 @@ func (u *EditorUI) startDragActor(doodad *doodads.Doodad, actor *level.Actor) {
if doodad == nil { if doodad == nil {
if actor != nil { if actor != nil {
obj, err := doodads.LoadFile(actor.Filename) obj, err := doodads.LoadFromEmbeddable(actor.Filename, u.Scene.Level)
if err != nil { if err != nil {
log.Error("startDragExistingActor: actor doodad name %s not found: %s", actor.Filename, err) log.Error("startDragExistingActor: actor doodad name %s not found: %s", actor.Filename, err)
return return

View File

@ -2,9 +2,13 @@ package doodle
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings"
"git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/doodads"
"git.kirsle.net/apps/doodle/pkg/level" "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/log"
"git.kirsle.net/apps/doodle/pkg/modal" "git.kirsle.net/apps/doodle/pkg/modal"
"git.kirsle.net/apps/doodle/pkg/windows" "git.kirsle.net/apps/doodle/pkg/windows"
@ -114,13 +118,27 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
Engine: d.Engine, Engine: d.Engine,
Level: scene.Level, Level: scene.Level,
OnPublish: func() { OnPublish: func(includeBuiltins bool) {
modal.Alert("Not Yet Implemented") log.Debug("OnPublish: include builtins=%+v", includeBuiltins)
// filename, err := native.SaveFile("Save your level", "*.level") cwd, _ := os.Getwd()
// if err != nil { d.Prompt(fmt.Sprintf("File name (relative to %s)> ", cwd), func(answer string) {
// modal.Alert(err.Error()) if answer == "" {
// } d.Flash("A file name is required to publish this level.")
// log.Info("Write to: %s", filename) 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() { OnCancel: func() {
u.publishWindow.Hide() u.publishWindow.Hide()

View File

@ -3,6 +3,7 @@ package level
import ( import (
"errors" "errors"
"sort" "sort"
"strings"
) )
// FileSystem embeds a map of files inside a parent drawing. // FileSystem embeds a map of files inside a parent drawing.
@ -64,3 +65,17 @@ func (l *Level) ListFiles() []string {
sort.Strings(files) sort.Strings(files)
return files return files
} }
// 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
}

View File

@ -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
}

View File

@ -150,12 +150,12 @@ func (w *Canvas) LoadLevel(e render.Engine, level *level.Level) {
w.Load(level.Palette, level.Chunker) w.Load(level.Palette, level.Chunker)
// TODO: wallpaper paths // TODO: wallpaper paths
filename := "assets/wallpapers/" + level.Wallpaper filename := balance.EmbeddedWallpaperBasePath + level.Wallpaper
if runtime.GOOS != "js" { if runtime.GOOS != "js" {
// Check if the wallpaper wasn't found. Check bindata and file system. // Check if the wallpaper wasn't found. Check bindata and file system.
if _, err := filesystem.FindFileEmbedded(filename, level); err != nil { if _, err := filesystem.FindFileEmbedded(filename, level); err != nil {
log.Error("LoadLevel: wallpaper %s did not appear to exist, default to notebook.png", filename) 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"
} }
} }

View File

@ -2,7 +2,7 @@ package uix
import ( import (
"errors" "errors"
"fmt" "strings"
"git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/doodads"
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
@ -14,11 +14,14 @@ import (
// InstallActors adds external Actors to the canvas to be superimposed on top // InstallActors adds external Actors to the canvas to be superimposed on top
// of the drawing. // of the drawing.
func (w *Canvas) InstallActors(actors level.ActorMap) error { func (w *Canvas) InstallActors(actors level.ActorMap) error {
var errs []string
w.actors = make([]*Actor, 0) w.actors = make([]*Actor, 0)
for id, actor := range actors { for id, actor := range actors {
doodad, err := doodads.LoadFile(actor.Filename) doodad, err := doodads.LoadFromEmbeddable(actor.Filename, w.level)
if err != nil { 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 // 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) w.actors = append(w.actors, liveActor)
} }
if len(errs) > 0 {
return errors.New(strings.Join(errs, "\n"))
}
return nil return nil
} }

View File

@ -189,41 +189,41 @@ func NewOpenLevelEditor(config OpenLevelEditor) *ui.Window {
} }
}(i, dd) }(i, dd)
} }
}
// Browse button for local filesystem. // Browse button for local filesystem.
browseDoodadFrame := ui.NewFrame("Browse Doodad Frame") browseDoodadFrame := ui.NewFrame("Browse Doodad Frame")
frame.Pack(browseDoodadFrame, ui.Pack{ frame.Pack(browseDoodadFrame, ui.Pack{
Side: ui.N, Side: ui.N,
Expand: true, Expand: true,
FillX: true, FillX: true,
PadY: 1, PadY: 1,
}) })
browseDoodadButton := ui.NewButton("Browse Doodad", ui.NewLabel(ui.Label{ browseDoodadButton := ui.NewButton("Browse Doodad", ui.NewLabel(ui.Label{
Text: "Browse...", Text: "Browse...",
Font: balance.MenuFont, Font: balance.MenuFont,
})) }))
browseDoodadButton.SetStyle(&balance.ButtonPrimary) browseDoodadButton.SetStyle(&balance.ButtonPrimary)
browseDoodadFrame.Pack(browseDoodadButton, ui.Pack{ browseDoodadFrame.Pack(browseDoodadButton, ui.Pack{
Side: ui.W, Side: ui.W,
}) })
browseDoodadButton.Handle(ui.Click, func(ed ui.EventData) error { browseDoodadButton.Handle(ui.Click, func(ed ui.EventData) error {
filename, err := native.OpenFile("Choose a .doodad file", "*.doodad") filename, err := native.OpenFile("Choose a .doodad file", "*.doodad")
if err != nil { if err != nil {
log.Error("Couldn't show file dialog: %s", err) log.Error("Couldn't show file dialog: %s", err)
return nil
}
if config.LoadForPlay {
config.OnPlayLevel(filename)
} else {
config.OnEditLevel(filename)
}
return nil return nil
} })
config.Supervisor.Add(browseDoodadButton)
if config.LoadForPlay { }
config.OnPlayLevel(filename)
} else {
config.OnEditLevel(filename)
}
return nil
})
config.Supervisor.Add(browseDoodadButton)
/****************** /******************
* Confirm/cancel buttons. * Confirm/cancel buttons.

View File

@ -3,12 +3,11 @@ package windows
import ( import (
"fmt" "fmt"
"math" "math"
"sort"
"strings" "strings"
"git.kirsle.net/apps/doodle/pkg/balance" "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"
"git.kirsle.net/apps/doodle/pkg/level/publishing"
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/ui" "git.kirsle.net/go/ui"
@ -21,7 +20,7 @@ type Publish struct {
Engine render.Engine Engine render.Engine
Level *level.Level Level *level.Level
OnPublish func() OnPublish func(builtinToo bool)
OnCancel func() OnCancel func()
// Private vars. // Private vars.
@ -124,39 +123,40 @@ func NewPublishWindow(cfg Publish) *ui.Window {
PadX: 2, PadX: 2,
}) })
// Collect all the doodad names in use in this level. // // Collect all the doodad names in use in this level.
unique := map[string]interface{}{} // unique := map[string]interface{}{}
names := []string{} // names := []string{}
if cfg.Level != nil { // if cfg.Level != nil {
for _, actor := range cfg.Level.Actors { // for _, actor := range cfg.Level.Actors {
if _, ok := unique[actor.Filename]; ok { // if _, ok := unique[actor.Filename]; ok {
continue // continue
} // }
unique[actor.Filename] = nil // unique[actor.Filename] = nil
names = append(names, actor.Filename) // names = append(names, actor.Filename)
} // }
} // }
sort.Strings(names) // sort.Strings(names)
// Identify which of the doodads are built-ins. // // Identify which of the doodads are built-ins.
usedBuiltins := []string{} // usedBuiltins := []string{}
builtinMap := map[string]interface{}{} // builtinMap := map[string]interface{}{}
usedCustom := []string{} // usedCustom := []string{}
if builtins, err := doodads.ListBuiltin(); err == nil { // if builtins, err := doodads.ListBuiltin(); err == nil {
for _, filename := range builtins { // for _, filename := range builtins {
if _, ok := unique[filename]; ok { // if _, ok := unique[filename]; ok {
usedBuiltins = append(usedBuiltins, filename) // usedBuiltins = append(usedBuiltins, filename)
builtinMap[filename] = nil // builtinMap[filename] = nil
} // }
} // }
} // }
for _, name := range names { // for _, name := range names {
if _, ok := builtinMap[name]; ok { // if _, ok := builtinMap[name]; ok {
continue // continue
} // }
usedCustom = append(usedCustom, name) // usedCustom = append(usedCustom, name)
} // }
usedBuiltins, usedCustom := publishing.GetUsedDoodadNames(cfg.Level)
// Helper function to draw the button rows for a set of doodads. // Helper function to draw the button rows for a set of doodads.
mkDoodadRows := func(filenames []string, builtin bool) []*ui.Frame { mkDoodadRows := func(filenames []string, builtin bool) []*ui.Frame {
@ -201,7 +201,7 @@ func NewPublishWindow(cfg Publish) *ui.Window {
builtinRows = []*ui.Frame{} builtinRows = []*ui.Frame{}
customRows = []*ui.Frame{} customRows = []*ui.Frame{}
) )
if len(names) > 0 { if len(usedCustom) > 0 {
customRows = mkDoodadRows(usedCustom, false) customRows = mkDoodadRows(usedCustom, false)
btnRows = append(btnRows, customRows...) btnRows = append(btnRows, customRows...)
} }
@ -284,7 +284,7 @@ func NewPublishWindow(cfg Publish) *ui.Window {
}{ }{
{"Export Level", true, func() { {"Export Level", true, func() {
if cfg.OnPublish != nil { if cfg.OnPublish != nil {
cfg.OnPublish() cfg.OnPublish(cfg.includeBuiltins)
} }
}}, }},
{"Close", false, func() { {"Close", false, func() {