2018-10-16 16:20:25 +00:00
|
|
|
package commands
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"image"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
|
|
|
"sort"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"image/png"
|
|
|
|
|
2022-09-24 22:17:25 +00:00
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/branding"
|
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/doodads"
|
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/level"
|
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
|
2023-02-18 05:09:11 +00:00
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/native"
|
2019-12-31 02:13:28 +00:00
|
|
|
"git.kirsle.net/go/render"
|
2020-11-15 23:20:15 +00:00
|
|
|
"github.com/urfave/cli/v2"
|
2018-10-16 16:20:25 +00:00
|
|
|
"golang.org/x/image/bmp"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Convert between image files (png or bitmap) and Doodle drawing files (levels
|
|
|
|
// and doodads)
|
2020-06-05 06:11:03 +00:00
|
|
|
var Convert *cli.Command
|
2018-10-16 16:20:25 +00:00
|
|
|
|
|
|
|
func init() {
|
2020-06-05 06:11:03 +00:00
|
|
|
Convert = &cli.Command{
|
2018-10-16 16:20:25 +00:00
|
|
|
Name: "convert",
|
|
|
|
Usage: "convert between images and Doodle drawing files",
|
|
|
|
ArgsUsage: "<input> <output>",
|
|
|
|
Flags: []cli.Flag{
|
2020-06-05 06:11:03 +00:00
|
|
|
&cli.StringFlag{
|
2018-10-16 16:20:25 +00:00
|
|
|
Name: "key",
|
2022-05-04 04:15:39 +00:00
|
|
|
Usage: "chroma key color for transparency on input image files, e.g. #ffffff",
|
|
|
|
Value: "",
|
2018-10-16 16:20:25 +00:00
|
|
|
},
|
2020-06-05 06:11:03 +00:00
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "title",
|
|
|
|
Aliases: []string{"t"},
|
|
|
|
Usage: "set the title of the level or doodad being created",
|
2019-04-17 07:02:41 +00:00
|
|
|
},
|
2020-06-05 06:11:03 +00:00
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "palette",
|
|
|
|
Aliases: []string{"p"},
|
|
|
|
Usage: "use a palette JSON to define color swatch properties",
|
2019-04-17 07:02:41 +00:00
|
|
|
},
|
2018-10-16 16:20:25 +00:00
|
|
|
},
|
|
|
|
Action: func(c *cli.Context) error {
|
2019-04-17 07:02:41 +00:00
|
|
|
if c.NArg() < 2 {
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit(
|
2019-04-17 07:02:41 +00:00
|
|
|
"Usage: doodad convert <input.png...> <output.doodad>\n"+
|
2018-10-16 16:20:25 +00:00
|
|
|
" Image file types: png, bmp\n"+
|
|
|
|
" Drawing file types: level, doodad",
|
|
|
|
1,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parse the chroma key.
|
2022-05-04 04:15:39 +00:00
|
|
|
var chroma = render.Invisible
|
|
|
|
if key := c.String("key"); key != "" {
|
|
|
|
color, err := render.HexColor(c.String("key"))
|
|
|
|
if err != nil {
|
|
|
|
return cli.Exit(
|
|
|
|
"Chrome key not a valid color: "+err.Error(),
|
|
|
|
1,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
chroma = color
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
|
2020-06-05 06:11:03 +00:00
|
|
|
args := c.Args().Slice()
|
2018-10-16 16:20:25 +00:00
|
|
|
var (
|
2019-04-17 07:02:41 +00:00
|
|
|
inputFiles = args[:len(args)-1]
|
|
|
|
inputType = strings.ToLower(filepath.Ext(inputFiles[0]))
|
|
|
|
outputFile = args[len(args)-1]
|
2018-10-16 16:20:25 +00:00
|
|
|
outputType = strings.ToLower(filepath.Ext(outputFile))
|
|
|
|
)
|
|
|
|
|
|
|
|
if inputType == extPNG || inputType == extBMP {
|
|
|
|
if outputType == extLevel || outputType == extDoodad {
|
2019-04-17 07:02:41 +00:00
|
|
|
if err := imageToDrawing(c, chroma, inputFiles, outputFile); err != nil {
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit(err.Error(), 1)
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit("Image inputs can only output to Doodle drawings", 1)
|
2018-10-16 16:20:25 +00:00
|
|
|
} else if inputType == extLevel || inputType == extDoodad {
|
|
|
|
if outputType == extPNG || outputType == extBMP {
|
2019-04-17 07:02:41 +00:00
|
|
|
if err := drawingToImage(c, chroma, inputFiles, outputFile); err != nil {
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit(err.Error(), 1)
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit("Doodle drawing inputs can only output to image files", 1)
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit("File types must be: png, bmp, level, doodad", 1)
|
2018-10-16 16:20:25 +00:00
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-04-17 07:02:41 +00:00
|
|
|
func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, outputFile string) error {
|
|
|
|
// Read the source images. Ensure they all have the same boundaries.
|
2018-10-16 16:20:25 +00:00
|
|
|
var (
|
2019-04-17 07:02:41 +00:00
|
|
|
imageBounds image.Point
|
2023-02-18 05:09:11 +00:00
|
|
|
width int // dimensions of the incoming image
|
2023-02-17 05:47:18 +00:00
|
|
|
height int
|
2019-04-17 07:02:41 +00:00
|
|
|
images []image.Image
|
2018-10-16 16:20:25 +00:00
|
|
|
)
|
2019-04-17 07:02:41 +00:00
|
|
|
|
|
|
|
for i, filename := range inputFiles {
|
|
|
|
reader, err := os.Open(filename)
|
|
|
|
if err != nil {
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit(err.Error(), 1)
|
2019-04-17 07:02:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
img, format, err := image.Decode(reader)
|
|
|
|
log.Info("Parsed image %d of %d. Format: %s", i+1, len(inputFiles), format)
|
|
|
|
if err != nil {
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit(err.Error(), 1)
|
2019-04-17 07:02:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Get the bounding box information of the source image.
|
|
|
|
var (
|
|
|
|
bounds = img.Bounds()
|
|
|
|
imageSize = bounds.Size()
|
|
|
|
)
|
|
|
|
|
|
|
|
// Validate all images are the same size.
|
|
|
|
if i == 0 {
|
|
|
|
imageBounds = imageSize
|
2023-02-18 05:09:11 +00:00
|
|
|
width = imageSize.X
|
|
|
|
height = imageSize.Y
|
2019-04-17 07:02:41 +00:00
|
|
|
} else if imageSize != imageBounds {
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit("your source images are not all the same dimensions", 1)
|
2019-04-17 07:02:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
images = append(images, img)
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
|
2024-05-27 22:14:00 +00:00
|
|
|
// Initialize the palette from a JSON file?
|
|
|
|
var palette *level.Palette
|
|
|
|
if paletteFile := c.String("palette"); paletteFile != "" {
|
|
|
|
log.Info("Loading initial palette from file: %s", paletteFile)
|
|
|
|
if p, err := level.LoadPaletteFromFile(paletteFile); err != nil {
|
|
|
|
return err
|
|
|
|
} else {
|
|
|
|
palette = p
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-05-05 23:32:30 +00:00
|
|
|
// Helper function to translate image filenames into layer names.
|
|
|
|
toLayerName := func(filename string) string {
|
|
|
|
ext := filepath.Ext(filename)
|
|
|
|
return strings.TrimSuffix(filepath.Base(filename), ext)
|
|
|
|
}
|
|
|
|
|
2018-10-16 16:20:25 +00:00
|
|
|
// Generate the output drawing file.
|
|
|
|
switch strings.ToLower(filepath.Ext(outputFile)) {
|
|
|
|
case extDoodad:
|
2023-02-17 05:47:18 +00:00
|
|
|
doodad := doodads.New(width, height)
|
2019-06-24 00:52:48 +00:00
|
|
|
doodad.GameVersion = branding.Version
|
2019-04-17 07:02:41 +00:00
|
|
|
doodad.Title = c.String("title")
|
|
|
|
if doodad.Title == "" {
|
|
|
|
doodad.Title = "Converted Doodad"
|
|
|
|
}
|
2024-04-19 05:31:11 +00:00
|
|
|
doodad.Author = native.DefaultAuthor
|
2019-04-17 07:02:41 +00:00
|
|
|
|
|
|
|
// Write the first layer and gather its palette.
|
2019-04-19 01:15:05 +00:00
|
|
|
log.Info("Converting first layer to drawing and getting the palette")
|
2023-02-18 05:09:11 +00:00
|
|
|
var chunkSize = doodad.ChunkSize8()
|
|
|
|
log.Info("Output is a Doodad file (%dx%d): %s", width, height, outputFile)
|
2024-05-27 22:14:00 +00:00
|
|
|
palette, layer0 := imageToChunker(images[0], chroma, palette, chunkSize)
|
2019-04-17 07:02:41 +00:00
|
|
|
doodad.Palette = palette
|
|
|
|
doodad.Layers[0].Chunker = layer0
|
2019-05-05 23:32:30 +00:00
|
|
|
doodad.Layers[0].Name = toLayerName(inputFiles[0])
|
2019-04-17 07:02:41 +00:00
|
|
|
|
|
|
|
// Write any additional layers.
|
|
|
|
if len(images) > 1 {
|
2019-05-05 23:32:30 +00:00
|
|
|
for i := 1; i < len(images); i++ {
|
|
|
|
img := images[i]
|
|
|
|
log.Info("Converting extra layer %d", i)
|
2019-04-19 01:15:05 +00:00
|
|
|
_, chunker := imageToChunker(img, chroma, palette, chunkSize)
|
2022-04-30 03:34:59 +00:00
|
|
|
doodad.AddLayer(toLayerName(inputFiles[i]), chunker)
|
2019-04-17 07:02:41 +00:00
|
|
|
}
|
|
|
|
}
|
2018-10-16 16:20:25 +00:00
|
|
|
|
|
|
|
err := doodad.WriteJSON(outputFile)
|
|
|
|
if err != nil {
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit(err.Error(), 1)
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
case extLevel:
|
|
|
|
log.Info("Output is a Level file: %s", outputFile)
|
2019-04-17 07:02:41 +00:00
|
|
|
if len(images) > 1 {
|
|
|
|
log.Warn("Notice: levels only support one layer so only your first image will be used")
|
|
|
|
}
|
2018-10-16 16:20:25 +00:00
|
|
|
|
|
|
|
lvl := level.New()
|
2019-06-24 00:52:48 +00:00
|
|
|
lvl.GameVersion = branding.Version
|
2023-02-17 05:47:18 +00:00
|
|
|
lvl.MaxWidth = int64(width)
|
|
|
|
lvl.MaxHeight = int64(height)
|
|
|
|
lvl.PageType = level.Bounded
|
2019-04-17 07:02:41 +00:00
|
|
|
lvl.Title = c.String("title")
|
|
|
|
if lvl.Title == "" {
|
|
|
|
lvl.Title = "Converted Level"
|
|
|
|
}
|
2024-04-19 05:31:11 +00:00
|
|
|
lvl.Author = native.DefaultAuthor
|
2024-05-27 22:14:00 +00:00
|
|
|
palette, chunker := imageToChunker(images[0], chroma, palette, lvl.Chunker.Size)
|
2019-04-17 07:02:41 +00:00
|
|
|
lvl.Palette = palette
|
|
|
|
lvl.Chunker = chunker
|
2018-10-16 16:20:25 +00:00
|
|
|
|
|
|
|
err := lvl.WriteJSON(outputFile)
|
|
|
|
if err != nil {
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit(err.Error(), 1)
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
default:
|
2021-09-04 04:35:12 +00:00
|
|
|
return cli.Exit("invalid output file: not a Doodle drawing", 1)
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-04-17 07:02:41 +00:00
|
|
|
func drawingToImage(c *cli.Context, chroma render.Color, inputFiles []string, outputFile string) error {
|
2018-10-16 16:20:25 +00:00
|
|
|
var palette *level.Palette
|
|
|
|
var chunker *level.Chunker
|
2019-04-17 07:02:41 +00:00
|
|
|
inputFile := inputFiles[0]
|
2018-10-16 16:20:25 +00:00
|
|
|
|
|
|
|
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
|
2024-05-27 22:14:00 +00:00
|
|
|
// palette: your palette so far (new distinct colors are added)
|
2023-02-17 05:47:18 +00:00
|
|
|
func imageToChunker(img image.Image, chroma render.Color, palette *level.Palette, chunkSize uint8) (*level.Palette, *level.Chunker) {
|
2018-10-16 16:20:25 +00:00
|
|
|
var (
|
2019-04-17 07:02:41 +00:00
|
|
|
chunker = level.NewChunker(chunkSize)
|
2018-10-16 16:20:25 +00:00
|
|
|
bounds = img.Bounds()
|
|
|
|
)
|
|
|
|
|
2019-04-19 01:15:05 +00:00
|
|
|
if palette == nil {
|
|
|
|
palette = level.NewPalette()
|
|
|
|
}
|
|
|
|
|
2018-10-16 16:20:25 +00:00
|
|
|
// Cache a palette of unique colors as we go.
|
|
|
|
var uniqueColor = map[string]*level.Swatch{}
|
2019-05-05 23:32:30 +00:00
|
|
|
var newColors = map[string]*level.Swatch{} // new ones discovered this time
|
2019-04-19 01:15:05 +00:00
|
|
|
for _, swatch := range palette.Swatches {
|
|
|
|
uniqueColor[swatch.Color.String()] = swatch
|
|
|
|
}
|
2018-10-16 16:20:25 +00:00
|
|
|
|
|
|
|
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
|
2019-05-05 23:32:30 +00:00
|
|
|
newColors[color.String()] = swatch
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
|
2019-12-31 02:13:28 +00:00
|
|
|
chunker.Set(render.NewPoint(x, y), swatch)
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Order the palette.
|
|
|
|
var sortedColors []string
|
|
|
|
for k := range uniqueColor {
|
|
|
|
sortedColors = append(sortedColors, k)
|
|
|
|
}
|
|
|
|
sort.Strings(sortedColors)
|
|
|
|
for _, hex := range sortedColors {
|
2019-05-05 23:32:30 +00:00
|
|
|
if _, ok := newColors[hex]; ok {
|
2023-02-18 20:45:36 +00:00
|
|
|
if err := palette.AddSwatch(uniqueColor[hex]); err != nil {
|
|
|
|
log.Error("Could not add more colors to the palette: %s", err)
|
|
|
|
panic(err.Error())
|
|
|
|
}
|
2019-05-05 23:32:30 +00:00
|
|
|
}
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|
2019-04-17 07:02:41 +00:00
|
|
|
palette.Inflate()
|
2018-10-16 16:20:25 +00:00
|
|
|
|
2019-04-17 07:02:41 +00:00
|
|
|
return palette, chunker
|
2018-10-16 16:20:25 +00:00
|
|
|
}
|