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.EditDoodad,
|
||||
commands.InstallScript,
|
||||
commands.LevelPack,
|
||||
}
|
||||
|
||||
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