From a75b7208caf72e92a132ecb98ae60eb7d81b7138 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Thu, 23 Dec 2021 19:15:32 -0800 Subject: [PATCH] Doodad Tool: Levelpacks Adds `doodad levelpack create` and `doodad levelpack show` commands to the CLI tool to create levelpacks. A levelpack is a ZIP file containing a descriptive index.json and directories for levels and doodads. --- cmd/doodad/commands/levelpack.go | 342 +++++++++++++++++++++++++++++++ cmd/doodad/main.go | 1 + pkg/levelpack/levelpack.go | 112 ++++++++++ 3 files changed, 455 insertions(+) create mode 100644 cmd/doodad/commands/levelpack.go create mode 100644 pkg/levelpack/levelpack.go diff --git a/cmd/doodad/commands/levelpack.go b/cmd/doodad/commands/levelpack.go new file mode 100644 index 0000000..8d5d441 --- /dev/null +++ b/cmd/doodad/commands/levelpack.go @@ -0,0 +1,342 @@ +package commands + +import ( + "archive/zip" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "git.kirsle.net/apps/doodle/pkg/doodads" + "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/levelpack" + "git.kirsle.net/apps/doodle/pkg/log" + "git.kirsle.net/apps/doodle/pkg/userdir" + "github.com/urfave/cli/v2" +) + +// LevelPack creation and management. +var LevelPack *cli.Command + +func init() { + LevelPack = &cli.Command{ + Name: "levelpack", + Usage: "create and manage .levelpack archives", + ArgsUsage: "-o output.levelpack ", + Subcommands: []*cli.Command{ + { + Name: "create", + Usage: "create a new .levelpack file from source files", + ArgsUsage: " [input.level...]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + Usage: "set a title for your levelpack, default will use the first level's title", + }, + &cli.StringFlag{ + Name: "author", + Aliases: []string{"a"}, + Usage: "set an author for your levelpack, default will use the first level's author", + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"d"}, + Usage: "set a description for your levelpack", + }, + &cli.IntFlag{ + Name: "free", + Aliases: []string{"f"}, + Usage: "set number of free levels (levels unlocked by default), 0 means all unlocked", + }, + &cli.StringFlag{ + Name: "doodads", + Aliases: []string{"D"}, + Usage: "which doodads to embed: none, custom, all", + Value: "all", + }, + }, + Action: levelpackCreate, + }, + { + Name: "show", + Usage: "print details about a levelpack file", + ArgsUsage: "", + Action: levelpackShow, + }, + }, + Flags: []cli.Flag{}, + } +} + +// Subcommand `levelpack show` +func levelpackShow(c *cli.Context) error { + if c.NArg() < 1 { + return cli.Exit( + "Usage: doodad levelpack show ", + 1, + ) + } + + var filename = c.Args().Slice()[0] + if !strings.HasSuffix(filename, ".levelpack") { + return cli.Exit("file must name a .levelpack", 1) + } + + lp, err := levelpack.LoadFile(filename) + if err != nil { + return cli.Exit(err, 1) + } + + fmt.Printf("===== Levelpack: %s =====\n", filename) + + fmt.Println("Headers:") + fmt.Printf(" Title: %s\n", lp.Title) + fmt.Printf(" Author: %s\n", lp.Author) + fmt.Printf(" Description: %s\n", lp.Description) + fmt.Printf(" Free levels: %d\n", lp.FreeLevels) + + // List the levels. + fmt.Println("\nLevels:") + for i, lvl := range lp.Levels { + fmt.Printf("%d. %s: %s\n", i+1, lvl.Filename, lvl.Title) + } + + // List the doodads. + dl := lp.ListFiles("doodads/") + if len(dl) > 0 { + fmt.Println("\nDoodads:") + for i, doodad := range dl { + fmt.Printf("%d. %s\n", i, doodad) + } + } + + return nil +} + +// Subcommand `levelpack create` +func levelpackCreate(c *cli.Context) error { + if c.NArg() < 2 { + return cli.Exit( + "Usage: doodad levelpack create ", + 1, + ) + } + + var ( + args = c.Args().Slice() + outfile = args[0] + infiles = args[1:] + title = c.String("title") + author = c.String("author") + description = c.String("description") + free = c.Int("free") + embedDoodads = c.String("doodads") + ) + + // Validate params. + if !strings.HasSuffix(outfile, ".levelpack") { + return cli.Exit("Output file must have a .levelpack extension", 1) + } + if embedDoodads != "none" && embedDoodads != "custom" && embedDoodads != "all" { + return cli.Exit( + "--doodads: must be one of all, custom, none", + 1, + ) + } + + var lp = levelpack.LevelPack{ + Title: title, + Author: author, + Description: description, + FreeLevels: free, + Created: time.Now().UTC(), + } + + // Create a temp directory to work with. + workdir, err := os.MkdirTemp(userdir.CacheDirectory, "levelpack-*") + if err != nil { + return cli.Exit( + fmt.Sprintf("Couldn't make temp folder: %s", err), + 1, + ) + } + log.Info("Working directory: %s", workdir) + defer os.RemoveAll(workdir) + + // Useful folders inside the working directory. + var ( + levelDir = filepath.Join(workdir, "levels") + doodadDir = filepath.Join(workdir, "doodads") + assets = []string{ + "index.json", + } + ) + os.MkdirAll(levelDir, 0755) + os.MkdirAll(doodadDir, 0755) + + // Get the list of the game's builtin doodads. + builtins, err := doodads.ListBuiltin() + if err != nil { + return cli.Exit(err, 1) + } + + // Read the input levels. + for i, filename := range infiles { + if !strings.HasSuffix(filename, ".level") { + return cli.Exit( + fmt.Sprintf("input file at position %d (%s) was not a .level file", i, filename), + 1, + ) + } + + lvl, err := level.LoadJSON(filename) + if err != nil { + return cli.Exit( + fmt.Sprintf("%s: %s", filename, err), + 1, + ) + } + + // Fill in defaults for --title, --author + if lp.Title == "" { + lp.Title = lvl.Title + } + if lp.Author == "" { + lp.Author = lvl.Author + } + + // Log the level in the index.json list. + lp.Levels = append(lp.Levels, levelpack.Level{ + Title: lvl.Title, + Author: lvl.Author, + Filename: filepath.Base(filename), + }) + + // Grab all the level's doodads to embed in the zip folder. + for _, actor := range lvl.Actors { + // What was the user's embeds request? (--doodads) + if embedDoodads == "none" { + break + } else if embedDoodads == "custom" { + // Custom doodads only. + if isBuiltinDoodad(builtins, actor.Filename) { + log.Warn("Doodad %s is a built-in, skipping embed", actor.Filename) + continue + } + } + + if _, err := os.Stat(filepath.Join(doodadDir, actor.Filename)); !os.IsNotExist(err) { + continue + } + + log.Info("New doodad: %s", actor.Filename) + + // Get this doodad from the game's built-ins or the user's + // profile directory only. Pulling embedded doodads out of + // the level is NOT supported. + asset, err := doodads.LoadFile(actor.Filename) + if err != nil { + return cli.Exit( + fmt.Sprintf("%s: Doodad file '%s': %s", filename, asset.Filename, err), + 1, + ) + } + + var targetFile = filepath.Join(doodadDir, actor.Filename) + assets = append(assets, targetFile) + log.Debug("Write doodad: %s", targetFile) + err = asset.WriteFile(filepath.Join(doodadDir, actor.Filename)) + if err != nil { + return cli.Exit( + fmt.Sprintf("Writing doodad %s: %s", actor.Filename, err), + 1, + ) + } + } + + // Copy the level in. + var targetFile = filepath.Join(levelDir, filepath.Base(filename)) + assets = append(assets, targetFile) + log.Info("Write level: %s", filename) + err = copyFile(filename, filepath.Join(levelDir, filepath.Base(filename))) + if err != nil { + return cli.Exit( + fmt.Sprintf("couldn't copy %s to %s: %s", filename, targetFile, err), + 1, + ) + } + } + + log.Info("Writing index.json") + if err := lp.WriteFile(filepath.Join(workdir, "index.json")); err != nil { + return cli.Exit(err, 1) + } + + // Zip the levelpack directory. + log.Info("Creating levelpack file: %s", outfile) + zipf, err := os.Create(outfile) + if err != nil { + return cli.Exit( + fmt.Sprintf("failed to create %s: %s", outfile, err), + 1, + ) + } + + zipper := zip.NewWriter(zipf) + defer zipper.Close() + + // Embed all the assets. + sort.Strings(assets) + for _, asset := range assets { + asset = strings.TrimPrefix(asset, workdir+"/") + log.Info("Zip: %s", asset) + err := zipFile(zipper, asset, filepath.Join(workdir, asset)) + if err != nil { + return cli.Exit(err, 1) + } + } + + log.Info("Written: %s", outfile) + return cli.Exit("", 0) +} + +// copyFile copies a file on disk to another location. +func copyFile(source, target string) error { + input, err := ioutil.ReadFile(source) + if err != nil { + return err + } + + return ioutil.WriteFile(target, input, 0644) +} + +// zipFile reads a file on disk to add to a zip file. +// The `key` is the filepath inside the ZIP file, filename is the actual source file on disk. +func zipFile(zf *zip.Writer, key, filename string) error { + input, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + + writer, err := zf.Create(key) + if err != nil { + return err + } + + _, err = writer.Write(input) + return err +} + +// Helper function to test whether a filename is part of the builtin doodads. +func isBuiltinDoodad(doodads []string, filename string) bool { + for _, cmp := range doodads { + if cmp == filename { + return true + } + } + return false +} diff --git a/cmd/doodad/main.go b/cmd/doodad/main.go index 205f007..07feecd 100644 --- a/cmd/doodad/main.go +++ b/cmd/doodad/main.go @@ -56,6 +56,7 @@ func main() { commands.EditLevel, commands.EditDoodad, commands.InstallScript, + commands.LevelPack, } sort.Sort(cli.FlagsByName(app.Flags)) diff --git a/pkg/levelpack/levelpack.go b/pkg/levelpack/levelpack.go new file mode 100644 index 0000000..346bb25 --- /dev/null +++ b/pkg/levelpack/levelpack.go @@ -0,0 +1,112 @@ +// Package levelpack handles ZIP archives for level packs. +package levelpack + +import ( + "archive/zip" + "encoding/json" + "errors" + "io/ioutil" + "os" + "strings" + "time" +) + +// LevelPack describes the contents of a levelpack file. +type LevelPack struct { + Title string `json:"title` + Description string `json:"description"` + Author string `json:"author"` + Created time.Time `json:"created"` + + // Cached metadata about the (displayed) levels. + Levels []Level `json:"levels"` + + // Number of levels unlocked by default. + // 0 = all levels unlocked + FreeLevels int `json:"freeLevels"` + + // The loaded zip file for reading an existing levelpack. + Zipfile *zip.Reader `json:"-"` +} + +// Level holds metadata about the levels in the levelpack. +type Level struct { + Title string `json:"title"` + Author string `json:"author"` + Filename string `json:"filename"` +} + +// LoadFile reads a .levelpack zip file. +func LoadFile(filename string) (LevelPack, error) { + stat, err := os.Stat(filename) + if err != nil { + return LevelPack{}, err + } + + fh, err := os.Open(filename) + if err != nil { + return LevelPack{}, err + } + + reader, err := zip.NewReader(fh, stat.Size()) + if err != nil { + return LevelPack{}, err + } + + lp := LevelPack{ + Zipfile: reader, + } + + // Read the index.json. + lp.GetJSON(&lp, "index.json") + + return lp, nil +} + +// WriteFile saves the metadata to a .json file on disk. +func (l LevelPack) WriteFile(filename string) error { + out, err := json.Marshal(l) + if err != nil { + return err + } + return ioutil.WriteFile(filename, out, 0655) +} + +// GetData returns file data from inside the loaded zipfile of a levelpack. +func (l LevelPack) GetData(filename string) ([]byte, error) { + if l.Zipfile == nil { + return []byte{}, errors.New("zipfile not loaded") + } + + file, err := l.Zipfile.Open(filename) + if err != nil { + return []byte{}, err + } + + return ioutil.ReadAll(file) +} + +// GetJSON loads a JSON file from the zipfile and marshals it into your struct. +func (l LevelPack) GetJSON(v interface{}, filename string) error { + data, err := l.GetData(filename) + if err != nil { + return err + } + + return json.Unmarshal(data, v) +} + +// ListFiles returns the files in the zipfile that match the prefix given. +func (l LevelPack) ListFiles(prefix string) []string { + var result []string + + if l.Zipfile != nil { + for _, file := range l.Zipfile.File { + if strings.HasPrefix(file.Name, prefix) { + result = append(result, file.Name) + } + } + } + + return result +}