From 5bf7d554f7c4dcced458dc27fd2f02b360b4f739 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 16 Oct 2018 09:20:25 -0700 Subject: [PATCH] Add doodad.exe binary and PNG to Drawing Converter Adds the `doodad` binary which will be a command line tool to work with Doodads and Levels and assist with development. The `doodad` binary has subcommands like git and the first command is `convert` which converts between image files (PNG or BMP) and Doodle drawing files (Level or Doodad). You can "screenshot" a level into a PNG or you can initialize a new drawing from a PNG. --- Makefile | 1 + README.md | 5 + cmd/doodad/README.md | 43 ++++++ cmd/doodad/commands/consts.go | 8 + cmd/doodad/commands/convert.go | 269 +++++++++++++++++++++++++++++++++ cmd/doodad/commands/log.go | 14 ++ cmd/doodad/main.go | 40 +++++ level/chunker.go | 47 ++++++ level/chunker_test.go | 85 +++++++++++ level/json.go | 11 +- render/color.go | 33 ++++ 11 files changed, 552 insertions(+), 4 deletions(-) create mode 100644 cmd/doodad/README.md create mode 100644 cmd/doodad/commands/consts.go create mode 100644 cmd/doodad/commands/convert.go create mode 100644 cmd/doodad/commands/log.go create mode 100644 cmd/doodad/main.go create mode 100644 level/chunker_test.go diff --git a/Makefile b/Makefile index c300d2d..2f08226 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ setup: clean build: gofmt -w . go build $(LDFLAGS) -i -o bin/doodle cmd/doodle/main.go + go build $(LDFLAGS) -i -o bin/doodad cmd/doodad/main.go # `make run` to run it in debug mode. .PHONY: run diff --git a/README.md b/README.md index 3f92ab7..01319ad 100644 --- a/README.md +++ b/README.md @@ -213,3 +213,8 @@ named: These are the open source **DejaVu Sans [Mono]** fonts, so copy them in from your `/usr/share/fonts/dejavu` folder or provide alternative fonts. + +```bash +mkdir fonts +cp /usr/share/fonts/dejavu/{DejaVuSans.ttf,DejaVuSans-Bold.ttf,DejaVuSansMono.ttf} fonts/ +``` diff --git a/cmd/doodad/README.md b/cmd/doodad/README.md new file mode 100644 index 0000000..87d9c8b --- /dev/null +++ b/cmd/doodad/README.md @@ -0,0 +1,43 @@ +# doodad.exe + +The doodad tool is a command line interface for interacting with Levels and +Doodad files, collectively referred to as "Doodle drawings" or just "drawings" +for short. + +# Commands + +## doodad convert + +Convert between standard image files (bitmap or PNG) and Doodle drawings +(levels or doodads). + +This command can be used to "export" a Doodle drawing as a PNG (when run against +a Level file, it may export a massive PNG image containing the entire level). +It may also "import" a new Doodle drawing from an image on disk. + +Example: + +```bash +# Export a full screenshot of your level +$ doodad convert mymap.level screenshot.png + +# Create a new level based from a PNG image. +$ doodad convert scanned-drawing.png new-level.level + +# Create a new doodad based from a BMP image, and in this image the chroma +# color (transparent) is #FF00FF instead of white as default. +$ doodad convert --key '#FF00FF' button.png button.doodad +``` + +Supported image types: + +* PNG (8-bit or 24-bit, with transparent pixels or chroma key) +* BMP (bitmap image with chroma key) + +The chrome key defaults to white (`#FFFFFF`), so pixels of that color are +treated as transparent and ignored. For PNG images, if a pixel is fully +transparent (alpha channel 0%) it will also be skipped. + +When converting an image into a drawing, the unique colors identified in the +drawing are extracted into the palette. You will need to later edit the palette +to assign meaning to the colors. diff --git a/cmd/doodad/commands/consts.go b/cmd/doodad/commands/consts.go new file mode 100644 index 0000000..301a3e6 --- /dev/null +++ b/cmd/doodad/commands/consts.go @@ -0,0 +1,8 @@ +package commands + +const ( + extLevel = ".level" + extDoodad = ".doodad" + extPNG = ".png" + extBMP = ".bmp" +) diff --git a/cmd/doodad/commands/convert.go b/cmd/doodad/commands/convert.go new file mode 100644 index 0000000..2ebfba3 --- /dev/null +++ b/cmd/doodad/commands/convert.go @@ -0,0 +1,269 @@ +package commands + +import ( + "errors" + "fmt" + "image" + "os" + "path/filepath" + "sort" + "strings" + + "image/png" + + "git.kirsle.net/apps/doodle" + "git.kirsle.net/apps/doodle/doodads" + "git.kirsle.net/apps/doodle/level" + "git.kirsle.net/apps/doodle/render" + "github.com/urfave/cli" + "golang.org/x/image/bmp" +) + +// Convert between image files (png or bitmap) and Doodle drawing files (levels +// and doodads) +var Convert cli.Command + +func init() { + Convert = cli.Command{ + Name: "convert", + Usage: "convert between images and Doodle drawing files", + ArgsUsage: " ", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "key", + Usage: "chroma key color for transparency on input image files", + Value: "#ffffff", + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() != 2 { + return cli.NewExitError( + "Usage: doodad convert \n"+ + " Image file types: png, bmp\n"+ + " Drawing file types: level, doodad", + 1, + ) + } + + // Parse the chroma key. + chroma, err := render.HexColor(c.String("key")) + if err != nil { + return cli.NewExitError( + "Chrome key not a valid color: "+err.Error(), + 1, + ) + } + + args := c.Args() + var ( + inputFile = args[0] + inputType = strings.ToLower(filepath.Ext(inputFile)) + outputFile = args[1] + outputType = strings.ToLower(filepath.Ext(outputFile)) + ) + + if inputType == extPNG || inputType == extBMP { + if outputType == extLevel || outputType == extDoodad { + if err := imageToDrawing(c, chroma, inputFile, outputFile); err != nil { + return cli.NewExitError(err.Error(), 1) + } + return nil + } + return cli.NewExitError("Image inputs can only output to Doodle drawings", 1) + } else if inputType == extLevel || inputType == extDoodad { + if outputType == extPNG || outputType == extBMP { + if err := drawingToImage(c, chroma, inputFile, outputFile); err != nil { + return cli.NewExitError(err.Error(), 1) + } + return nil + } + return cli.NewExitError("Doodle drawing inputs can only output to image files", 1) + } + + return cli.NewExitError("File types must be: png, bmp, level, doodad", 1) + }, + } +} + +func imageToDrawing(c *cli.Context, chroma render.Color, inputFile, outputFile string) error { + reader, err := os.Open(inputFile) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + img, format, err := image.Decode(reader) + log.Info("format: %s", format) + _ = img + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + // Get the bounding box information of the source image. + var ( + bounds = img.Bounds() + imageSize = bounds.Size() + chunkSize int // the square shape for Doodad chunk size + ) + if imageSize.X > imageSize.Y { + chunkSize = imageSize.X + } else { + chunkSize = imageSize.Y + } + + // Generate the output drawing file. + switch strings.ToLower(filepath.Ext(outputFile)) { + case extDoodad: + log.Info("Output is a Doodad file (chunk size %d): %s", chunkSize, outputFile) + doodad := doodads.New(chunkSize) + doodad.GameVersion = doodle.Version + doodad.Title = "Converted Doodad" + doodad.Author = os.Getenv("USER") + doodad.Palette = imageToChunker(img, chroma, doodad.Layers[0].Chunker) + + err := doodad.WriteJSON(outputFile) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + case extLevel: + log.Info("Output is a Level file: %s", outputFile) + + lvl := level.New() + lvl.GameVersion = doodle.Version + lvl.Title = "Converted Level" + lvl.Author = os.Getenv("USER") + lvl.Palette = imageToChunker(img, chroma, lvl.Chunker) + + err := lvl.WriteJSON(outputFile) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + default: + return cli.NewExitError("invalid output file: not a Doodle drawing", 1) + } + + return nil +} + +func drawingToImage(c *cli.Context, chroma render.Color, inputFile, outputFile string) error { + var palette *level.Palette + var chunker *level.Chunker + + switch strings.ToLower(filepath.Ext(inputFile)) { + case extLevel: + log.Info("Load Level: %s", inputFile) + m, err := level.LoadJSON(inputFile) + if err != nil { + return fmt.Errorf("load level: %s", err.Error()) + } + chunker = m.Chunker + palette = m.Palette + case extDoodad: + log.Info("Load Doodad: %s", inputFile) + d, err := doodads.LoadJSON(inputFile) + if err != nil { + return fmt.Errorf("load doodad: %s", err.Error()) + } + chunker = d.Layers[0].Chunker // TODO: layers + palette = d.Palette + default: + return fmt.Errorf("%s: not a level or doodad file", inputFile) + } + + _ = chunker + _ = palette + + // Create an image for the full world size. + canvas := chunker.WorldSizePositive() + img := image.NewRGBA(image.Rectangle{ + Min: image.Point{ + X: int(canvas.X), + Y: int(canvas.Y), + }, + Max: image.Point{ + X: int(canvas.W), + Y: int(canvas.H), + }, + }) + + // Blank out the pixels. + for x := 0; x < img.Bounds().Max.X; x++ { + for y := 0; y < img.Bounds().Max.Y; y++ { + img.Set(x, y, render.White.ToColor()) + } + } + + // Transcode all pixels onto it. + for px := range chunker.IterPixels() { + img.Set(int(px.X), int(px.Y), px.Swatch.Color.ToColor()) + } + + // Write the output file. + switch strings.ToLower(filepath.Ext(outputFile)) { + case ".png": + fh, err := os.Create(outputFile) + if err != nil { + return err + } + defer fh.Close() + return png.Encode(fh, img) + case ".bmp": + fh, err := os.Create(outputFile) + if err != nil { + return err + } + defer fh.Close() + return bmp.Encode(fh, img) + } + + return errors.New("not valid output image type") +} + +// imageToChunker implements a generic transcoding of an image.Image to a Chunker +// and returns the Palette, ready to plug into a Doodad or Level drawing. +// +// img: input image like a PNG +// chroma: transparent color +func imageToChunker(img image.Image, chroma render.Color, chunker *level.Chunker) *level.Palette { + var ( + palette = level.NewPalette() + bounds = img.Bounds() + ) + + // Cache a palette of unique colors as we go. + var uniqueColor = map[string]*level.Swatch{} + + for x := bounds.Min.X; x < bounds.Max.X; x++ { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + px := img.At(x, y) + color := render.FromColor(px) + if color == chroma || color.Transparent() { // invisible pixel + continue + } + + // New color for the palette? + swatch, ok := uniqueColor[color.String()] + if !ok { + log.Info("New color: %s", color) + swatch = &level.Swatch{ + Name: color.String(), + Color: color, + } + uniqueColor[color.String()] = swatch + } + + chunker.Set(render.NewPoint(int32(x), int32(y)), swatch) + } + } + + // Order the palette. + var sortedColors []string + for k := range uniqueColor { + sortedColors = append(sortedColors, k) + } + sort.Strings(sortedColors) + for _, hex := range sortedColors { + palette.Swatches = append(palette.Swatches, uniqueColor[hex]) + } + + return palette +} diff --git a/cmd/doodad/commands/log.go b/cmd/doodad/commands/log.go new file mode 100644 index 0000000..2ca717f --- /dev/null +++ b/cmd/doodad/commands/log.go @@ -0,0 +1,14 @@ +package commands + +import "github.com/kirsle/golog" + +var log *golog.Logger + +func init() { + log = golog.GetLogger("doodad") + log.Configure(&golog.Config{ + Level: golog.InfoLevel, + Theme: golog.DarkTheme, + Colors: golog.ExtendedColor, + }) +} diff --git a/cmd/doodad/main.go b/cmd/doodad/main.go new file mode 100644 index 0000000..b976f43 --- /dev/null +++ b/cmd/doodad/main.go @@ -0,0 +1,40 @@ +// doodad is the command line developer tool for Doodle. +package main + +import ( + "log" + "os" + "sort" + + "git.kirsle.net/apps/doodle" + "git.kirsle.net/apps/doodle/cmd/doodad/commands" + "github.com/urfave/cli" +) + +var Build = "N/A" + +func main() { + app := cli.NewApp() + app.Name = "doodad" + app.Usage = "command line interface for Doodle" + app.Version = doodle.Version + " build " + Build + + app.Flags = []cli.Flag{ + cli.BoolFlag{ + Name: "debug, d", + Usage: "enable debug level logging", + }, + } + + app.Commands = []cli.Command{ + commands.Convert, + } + + sort.Sort(cli.FlagsByName(app.Flags)) + sort.Sort(cli.CommandsByName(app.Commands)) + + err := app.Run(os.Args) + if err != nil { + log.Fatal(err) + } +} diff --git a/level/chunker.go b/level/chunker.go index e4312b3..501d529 100644 --- a/level/chunker.go +++ b/level/chunker.go @@ -81,6 +81,53 @@ func (c *Chunker) IterPixels() <-chan Pixel { return pipe } +// WorldSize returns the bounding coordinates that the Chunker has chunks to +// manage: the lowest pixels from the lowest chunks to the highest pixels of +// the highest chunks. +func (c *Chunker) WorldSize() render.Rect { + // Lowest and highest chunks. + var ( + chunkLowest render.Point + chunkHighest render.Point + size = int32(c.Size) + ) + + for coord := range c.Chunks { + if coord.X < chunkLowest.X { + chunkLowest.X = coord.X + } + if coord.Y < chunkLowest.Y { + chunkLowest.Y = coord.Y + } + + if coord.X > chunkHighest.X { + chunkHighest.X = coord.X + } + if coord.Y > chunkHighest.Y { + chunkHighest.Y = coord.Y + } + } + + return render.Rect{ + X: chunkLowest.X * size, + Y: chunkLowest.Y * size, + W: (chunkHighest.X * size) + (size - 1), + H: (chunkHighest.Y * size) + (size - 1), + } +} + +// WorldSizePositive returns the WorldSize anchored to 0,0 with only positive +// coordinates. +func (c *Chunker) WorldSizePositive() render.Rect { + S := c.WorldSize() + return render.Rect{ + X: 0, + Y: 0, + W: int32(math.Abs(float64(S.X))) + S.W, + H: int32(math.Abs(float64(S.Y))) + S.H, + } +} + // GetChunk gets a chunk at a certain position. Returns false if not found. func (c *Chunker) GetChunk(p render.Point) (*Chunk, bool) { chunk, ok := c.Chunks[p] diff --git a/level/chunker_test.go b/level/chunker_test.go new file mode 100644 index 0000000..8d02fb6 --- /dev/null +++ b/level/chunker_test.go @@ -0,0 +1,85 @@ +package level_test + +import ( + "testing" + + "git.kirsle.net/apps/doodle/level" + "git.kirsle.net/apps/doodle/render" +) + +func TestWorldSize(t *testing.T) { + type TestCase struct { + Size int + Points []render.Point + Expect render.Rect + Zero render.Rect // expected WorldSizePositive + } + var tests = []TestCase{ + { + Size: 1000, + Points: []render.Point{ + render.NewPoint(0, 0), // chunk 0,0 + render.NewPoint(512, 788), // 0,0 + render.NewPoint(1002, 500), // 1,0 + render.NewPoint(2005, 2006), // 2,2 + render.NewPoint(-5, -5), // -1,-1 + }, + Expect: render.Rect{ + X: -1000, + Y: -1000, + W: 2999, + H: 2999, + }, + Zero: render.NewRect(3999, 3999), + }, + { + Size: 128, + Points: []render.Point{ + render.NewPoint(5, 5), + }, + Expect: render.Rect{ + X: 0, + Y: 0, + W: 127, + H: 127, + }, + Zero: render.NewRect(127, 127), + }, + { + Size: 200, + Points: []render.Point{ + render.NewPoint(-6000, -38556), + render.NewPoint(12345, 1288000), + }, + Expect: render.Rect{ + X: -6000, + Y: -38600, + W: 12399, + H: 1288199, + }, + Zero: render.NewRect(18399, 1326799), + }, + } + for _, test := range tests { + c := level.NewChunker(test.Size) + sw := &level.Swatch{ + Name: "solid", + Color: render.Black, + } + + for _, pt := range test.Points { + c.Set(pt, sw) + } + + size := c.WorldSize() + if size != test.Expect { + t.Errorf("WorldSize not as expected: %s <> %s", size, test.Expect) + } + + zero := c.WorldSizePositive() + if zero != test.Zero { + t.Errorf("WorldSizePositive not as expected: %s <> %s", zero, test.Expect) + } + } + +} diff --git a/level/json.go b/level/json.go index ff14de7..7cd89f6 100644 --- a/level/json.go +++ b/level/json.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io/ioutil" "os" ) @@ -18,13 +19,15 @@ func (m *Level) ToJSON() ([]byte, error) { // WriteJSON writes a level to JSON on disk. func (m *Level) WriteJSON(filename string) error { - fh, err := os.Create(filename) + json, err := m.ToJSON() if err != nil { - return fmt.Errorf("Level.WriteJSON(%s): failed to create file: %s", filename, err) + return fmt.Errorf("Level.WriteJSON: JSON encode error: %s", err) } - defer fh.Close() - _ = fh + err = ioutil.WriteFile(filename, json, 0755) + if err != nil { + return fmt.Errorf("Level.WriteJSON: WriteFile error: %s", err) + } return nil } diff --git a/render/color.go b/render/color.go index 7a88637..c16cda2 100644 --- a/render/color.go +++ b/render/color.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "image/color" "regexp" "strconv" ) @@ -36,6 +37,22 @@ func RGBA(r, g, b, a uint8) Color { } } +// FromColor creates a render.Color from a Go color.Color +func FromColor(from color.Color) Color { + // downscale a 16-bit color value to 8-bit. input range 0x0000..0xffff + downscale := func(in uint32) uint8 { + var scale = float64(in) / 0xffff + return uint8(scale * 0xff) + } + r, g, b, a := from.RGBA() + return RGBA( + downscale(r), + downscale(g), + downscale(b), + downscale(a), + ) +} + // MustHexColor parses a color from hex code or panics. func MustHexColor(hex string) Color { color, err := HexColor(hex) @@ -94,6 +111,22 @@ func (c Color) String() string { ) } +// ToColor converts a render.Color into a Go standard color.Color +func (c Color) ToColor() color.RGBA { + return color.RGBA{ + R: c.Red, + G: c.Green, + B: c.Blue, + A: c.Alpha, + } +} + +// Transparent returns whether the alpha channel is zeroed out and the pixel +// won't appear as anything when rendered. +func (c Color) Transparent() bool { + return c.Alpha == 0x00 +} + // MarshalJSON serializes the Color for JSON. func (c Color) MarshalJSON() ([]byte, error) { return []byte(fmt.Sprintf(