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.
This commit is contained in:
parent
ddf0074099
commit
a75b7208ca
342
cmd/doodad/commands/levelpack.go
Normal file
342
cmd/doodad/commands/levelpack.go
Normal file
|
@ -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 <list of .level files>",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Usage: "create a new .levelpack file from source files",
|
||||||
|
ArgsUsage: "<output.levelpack> <input.level> [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: "<input.levelpack>",
|
||||||
|
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 <file.levelpack>",
|
||||||
|
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 <out.levelpack> <in.level ...>",
|
||||||
|
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
|
||||||
|
}
|
|
@ -56,6 +56,7 @@ func main() {
|
||||||
commands.EditLevel,
|
commands.EditLevel,
|
||||||
commands.EditDoodad,
|
commands.EditDoodad,
|
||||||
commands.InstallScript,
|
commands.InstallScript,
|
||||||
|
commands.LevelPack,
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Sort(cli.FlagsByName(app.Flags))
|
sort.Sort(cli.FlagsByName(app.Flags))
|
||||||
|
|
112
pkg/levelpack/levelpack.go
Normal file
112
pkg/levelpack/levelpack.go
Normal file
|
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user