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(