From 3c916f56ee0c5f0adee73440c43d7edc085cdff2 Mon Sep 17 00:00:00 2001 From: Torben Date: Mon, 28 Feb 2022 13:24:28 -0800 Subject: [PATCH] Support advanced tail/head customizations (#25) See https://github.com/BattlesnakeOfficial/exporter/pull/25 --- .gitignore | 1 + go.mod | 1 + go.sum | 2 + imagetest/imagetest.go | 33 ++ inkscape/wrapper_test.go | 23 +- media/api.go | 14 +- media/image.go | 17 + media/media.go | 126 +++++- media/media_internal_test.go | 91 +++- media/testdata/default_00ccaa.png | Bin 0 -> 977 bytes parse/color.go | 31 ++ parse/color_test.go | 31 ++ render/board.go | 20 +- render/board_internal_test.go | 23 +- render/gif.go | 57 +-- render/gif_internal_test.go | 97 ----- render/gif_test.go | 525 +++++++++++++++++++++++ render/image.go | 64 +-- render/testdata/TestHappyPath_golden.gif | Bin 0 -> 6051 bytes 19 files changed, 874 insertions(+), 282 deletions(-) create mode 100644 imagetest/imagetest.go create mode 100644 media/image.go create mode 100644 media/testdata/default_00ccaa.png create mode 100644 parse/color.go create mode 100644 parse/color_test.go delete mode 100644 render/gif_internal_test.go create mode 100644 render/gif_test.go create mode 100644 render/testdata/TestHappyPath_golden.gif diff --git a/.gitignore b/.gitignore index a2d0738..a029671 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ bin/ media/assets/downloads/ +render/media/assets/downloads/ # General .DS_Store diff --git a/go.mod b/go.mod index fa1768d..0a3881e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.17 require ( github.com/disintegration/imaging v1.6.2 + github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 github.com/fogleman/gg v1.3.0 github.com/julienschmidt/httprouter v1.3.0 github.com/patrickmn/go-cache v2.1.0+incompatible diff --git a/go.sum b/go.sum index 0dbd5b7..05eb944 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU= +github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= diff --git a/imagetest/imagetest.go b/imagetest/imagetest.go new file mode 100644 index 0000000..afd36ea --- /dev/null +++ b/imagetest/imagetest.go @@ -0,0 +1,33 @@ +package imagetest + +import ( + "fmt" + "image" + "image/color" + "testing" + + "github.com/stretchr/testify/require" +) + +// Equal ensures that the two images are equivalent. +// Equivalent means that both images have the same bounds and each pixel has the same RGBA values. +func Equal(t *testing.T, a, b image.Image) { + require.Equal(t, a.Bounds().Min.X, b.Bounds().Min.X) + require.Equal(t, a.Bounds().Min.Y, b.Bounds().Min.Y) + require.Equal(t, a.Bounds().Max.X, b.Bounds().Max.X) + require.Equal(t, a.Bounds().Max.Y, b.Bounds().Max.Y) + + for x := 0; x < a.Bounds().Max.X; x++ { + for y := 0; y < a.Bounds().Max.Y; y++ { + c1 := a.At(x, y) + c2 := b.At(x, y) + sameColor(t, c1, c2, x, y) + } + } +} + +func sameColor(t *testing.T, c1, c2 color.Color, x, y int) { + r1, g1, b1, a1 := c1.RGBA() + r2, g2, b2, a2 := c2.RGBA() + require.True(t, r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2, fmt.Sprintf("%v != %v at pixel (%d,%d)", c1, c2, x, y)) +} diff --git a/inkscape/wrapper_test.go b/inkscape/wrapper_test.go index 109663c..530676c 100644 --- a/inkscape/wrapper_test.go +++ b/inkscape/wrapper_test.go @@ -3,11 +3,11 @@ package inkscape_test import ( "errors" "image" - "image/color" "io/fs" "os" "testing" + "github.com/BattlesnakeOfficial/exporter/imagetest" "github.com/BattlesnakeOfficial/exporter/inkscape" "github.com/stretchr/testify/require" ) @@ -20,7 +20,7 @@ func TestSVGToPNG(t *testing.T) { require.Equal(t, 100, got.Bounds().Max.X) require.Equal(t, 100, got.Bounds().Max.Y) want := loadTestImage(t) - same(t, want, got) + imagetest.Equal(t, want, got) // client should validate width/height _, err = client.SVGToPNG("testdata/example.svg", 0, 100) @@ -52,25 +52,6 @@ func TestIsAvailable(t *testing.T) { require.False(t, client.IsAvailable()) } -func same(t *testing.T, a, b image.Image) { - require.Equal(t, a.Bounds().Max.X, b.Bounds().Max.X) - require.Equal(t, a.Bounds().Max.Y, b.Bounds().Max.Y) - - for x := 0; x < a.Bounds().Max.X; x++ { - for y := 0; y < a.Bounds().Min.Y; y++ { - c1 := a.At(x, y) - c2 := b.At(x, y) - sameColor(t, c1, c2) - } - } -} - -func sameColor(t *testing.T, c1, c2 color.Color) { - r1, g1, b1, a1 := c1.RGBA() - r2, g2, b2, a2 := c1.RGBA() - require.True(t, r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2) -} - func loadTestImage(t *testing.T) image.Image { f, err := os.Open("testdata/example.png") require.NoError(t, err) diff --git a/media/api.go b/media/api.go index b956d69..9aad5b5 100644 --- a/media/api.go +++ b/media/api.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "image" + "image/color" "io/ioutil" "net/http" "time" @@ -15,11 +16,6 @@ import ( var ErrNotFound = errors.New("resource not found") var mediaServerURL = "https://media.battlesnake.com" -const ( - fallbackHeadID = "default" - fallbackTailID = "default" -) - // Create an in-mem media cache (6 hours, evicting every 10 mins) var mediaCache = cache.New(6*60*time.Minute, 10*time.Minute) @@ -72,12 +68,12 @@ func GetTailSVG(id string) (string, error) { return getCachedMediaResource(tailSVGPath(id)) } -func GetHeadPNG(id string, w, h int) (image.Image, error) { - return getSVGImageWithFallback(headSVGPath(id), headSVGPath(fallbackHeadID), w, h) +func GetHeadPNG(id string, w, h int, c color.Color) (image.Image, error) { + return getSnakeSVGImage(headSVGPath(id), fallbackHead, w, h, c) } -func GetTailPNG(id string, w, h int) (image.Image, error) { - return getSVGImageWithFallback(tailSVGPath(id), tailSVGPath(fallbackTailID), w, h) +func GetTailPNG(id string, w, h int, c color.Color) (image.Image, error) { + return getSnakeSVGImage(tailSVGPath(id), fallbackTail, w, h, c) } func headSVGPath(id string) string { diff --git a/media/image.go b/media/image.go new file mode 100644 index 0000000..154570f --- /dev/null +++ b/media/image.go @@ -0,0 +1,17 @@ +package media + +import ( + "image" + "image/color" + "image/draw" +) + +func changeImageColor(src image.Image, c color.Color) image.Image { + dstRect := image.Rect(src.Bounds().Min.X, src.Bounds().Min.Y, src.Bounds().Max.X, src.Bounds().Max.Y) + dst := image.NewNRGBA(dstRect) + + srcImage := &image.Uniform{c} + + draw.DrawMask(dst, dstRect, srcImage, image.Point{}, src, image.Point{}, draw.Over) + return dst +} diff --git a/media/media.go b/media/media.go index 8402969..78be995 100644 --- a/media/media.go +++ b/media/media.go @@ -1,13 +1,19 @@ package media import ( + "bytes" + "encoding/xml" "errors" "fmt" "image" + "image/color" + "io" "io/fs" "io/ioutil" "os" + "path" "path/filepath" + "strings" "time" "github.com/BattlesnakeOfficial/exporter/inkscape" @@ -16,6 +22,11 @@ import ( log "github.com/sirupsen/logrus" ) +const ( + fallbackHead = "heads/default.png" // relative to base path + fallbackTail = "tails/default.png" // relative to base path +) + // imageCache is a cache that contains image.Image values var imageCache = cache.New(time.Hour, 10*time.Minute) @@ -27,7 +38,7 @@ var svgMgr = &svgManager{ // GetWatermarkPNG gets the watermark asset, scaled to the requested width/height func GetWatermarkPNG(w, h int) (image.Image, error) { - return loadLocalImageAsset(filepath.Join(baseDir, "watermark.png"), w, h) + return loadLocalImageAsset("watermark.png", w, h) } func loadImageFile(path string) (image.Image, error) { @@ -41,20 +52,26 @@ func loadImageFile(path string) (image.Image, error) { return img, err } -func imageCacheKey(path string, w, h int) string { - return fmt.Sprintf("%s:%d:%d", path, w, h) +// imageCacheKey creates a cache key that is unique to the given parameters. +// color can be nil when there is no color. +func imageCacheKey(path string, w, h int, c color.Color) string { + return fmt.Sprintf("%s:%d:%d:%s", path, w, h, colorToHex6(c)) } +// loadLocalImageAsset loads the specified media asset from the local filesystem. +// It assumes the "mediaPath" is relative to the base path. +// The base path is the directory where all media assets should be located within. func loadLocalImageAsset(mediaPath string, w, h int) (image.Image, error) { - key := imageCacheKey(mediaPath, w, h) + key := imageCacheKey(mediaPath, w, h, nil) cachedImage, ok := imageCache.Get(key) if ok { return cachedImage.(image.Image), nil } - img, err := loadImageFile(mediaPath) + fullPath := filepath.Join(baseDir, mediaPath) // file is within the baseDir on disk + img, err := loadImageFile(fullPath) if err != nil { - log.WithField("path", mediaPath).WithError(err).Errorf("Error loading asset from file") + log.WithField("path", fullPath).WithError(err).Errorf("Error loading asset from file") return nil, err } img = scaleImage(img, w, h) @@ -63,9 +80,9 @@ func loadLocalImageAsset(mediaPath string, w, h int) (image.Image, error) { return img, nil } -func getSVGImageWithFallback(path, fallbackPath string, w, h int) (image.Image, error) { +func getSnakeSVGImage(path, fallbackPath string, w, h int, c color.Color) (image.Image, error) { // first we try to load from the media server SVG's - img, err := svgMgr.loadSVGImage(path, w, h) + img, err := svgMgr.loadSnakeSVGImage(path, w, h, c) if err != nil { // log at info, because this could error just for people specifying snake types that don't exist log.WithFields(log.Fields{ @@ -80,14 +97,16 @@ func getSVGImageWithFallback(path, fallbackPath string, w, h int) (image.Image, "path": path, "fallback": fallbackPath, }).WithError(err).Error("Unable to load local fallback image from file") + return nil, err } + img = changeImageColor(img, c) } return img, err } -func (sm svgManager) loadSVGImage(mediaPath string, w, h int) (image.Image, error) { - key := imageCacheKey(mediaPath, w, h) +func (sm svgManager) loadSnakeSVGImage(mediaPath string, w, h int, c color.Color) (image.Image, error) { + key := imageCacheKey(mediaPath, w, h, c) cachedImage, ok := imageCache.Get(key) if ok { return cachedImage.(image.Image), nil @@ -98,10 +117,11 @@ func (sm svgManager) loadSVGImage(mediaPath string, w, h int) (image.Image, erro return nil, errors.New("inkscape is not available - unable to load SVG") } - err := sm.ensureDownloaded(mediaPath) + mediaPath, err := sm.ensureDownloaded(mediaPath, c) if err != nil { return nil, err } + path := sm.getFullPath(mediaPath) // rasterize the SVG @@ -143,21 +163,82 @@ func (sm svgManager) getFullPath(mediaPath string) string { return filepath.Join(sm.baseDir, mediaPath) } -func (sm svgManager) ensureDownloaded(mediaPath string) error { +func (sm svgManager) ensureDownloaded(mediaPath string, c color.Color) (string, error) { + // use the colour as a directory to separate different colours of SVG's + customizedMediaPath := path.Join(colorToHex6(c), mediaPath) + // check if we need to download the SVG from the media server - _, err := os.Stat(sm.getFullPath(mediaPath)) + _, err := os.Stat(sm.getFullPath(customizedMediaPath)) if errors.Is(err, fs.ErrNotExist) { svg, err := getCachedMediaResource(mediaPath) if err != nil { - return err + return "", err + } + + svg = customiseSnakeSVG(svg, c) + + err = sm.writeFile(customizedMediaPath, []byte(svg)) + if err != nil { + return "", err + } + } + + // return the new media path which includes the colour directory + return customizedMediaPath, nil +} + +// customiseSnakeSVG sets the fill colour for the outer SVG tag +func customiseSnakeSVG(svg string, c color.Color) string { + var buf bytes.Buffer + decoder := xml.NewDecoder(strings.NewReader(svg)) + encoder := xml.NewEncoder(&buf) + + rootSVGFound := false + + for { + token, err := decoder.Token() + if err == io.EOF { + break } - err = sm.writeFile(mediaPath, []byte(svg)) if err != nil { - return err + log.WithError(err).Info("error while decoding SVG token") + break // skip this token + } + token = xml.CopyToken(token) + switch v := (token).(type) { + case xml.StartElement: + + // check if this is the root SVG tag that we can change the colour on + if !rootSVGFound && v.Name.Local == "svg" { + rootSVGFound = true + attrs := append(v.Attr, xml.Attr{Name: xml.Name{Local: "fill"}, Value: colorToHex6(c)}) + (&v).Attr = attrs + } + + // this is necessary to prevent a weird behavior in Go's XML serialization where every tag gets the + // "xmlns" set, even if it already has that as an attribute + // see also: https://github.com/golang/go/issues/7535 + (&v).Name.Space = "" + token = v + case xml.EndElement: + // this is necessary to prevent a weird behavior in Go's XML serialization where every tag gets the + // "xmlns" set, even if it already has that as an attribute + // see also: https://github.com/golang/go/issues/7535 + (&v).Name.Space = "" + token = v + } + + if err := encoder.EncodeToken(token); err != nil { + log.Fatal(err) } } - return nil + // must call flush, otherwise some elements will be missing + if err := encoder.Flush(); err != nil { + log.Fatal(err) + } + + return buf.String() } func scaleImage(src image.Image, w, h int) image.Image { @@ -167,3 +248,14 @@ func scaleImage(src image.Image, w, h int) image.Image { } return imaging.Resize(src, w, h, imaging.Lanczos) } + +// colorToHex6 converts a color.Color to a 6-digit hexadecimal string. +// If color is nil, the empty string is returned. +func colorToHex6(c color.Color) string { + if c == nil { + return "" + } + + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02x%02x%02x", uint8(r), uint8(g), uint8(b)) +} diff --git a/media/media_internal_test.go b/media/media_internal_test.go index 017c851..159b13a 100644 --- a/media/media_internal_test.go +++ b/media/media_internal_test.go @@ -3,6 +3,7 @@ package media import ( "fmt" "image" + "image/color" "net/http" "net/http/httptest" "os" @@ -10,7 +11,9 @@ import ( "strings" "testing" + "github.com/BattlesnakeOfficial/exporter/imagetest" "github.com/BattlesnakeOfficial/exporter/inkscape" + "github.com/BattlesnakeOfficial/exporter/parse" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -57,13 +60,13 @@ func TestGetTailSVG(t *testing.T) { } func TestGetTailPNG(t *testing.T) { - img, err := GetTailPNG("default", 20, 20) + img, err := GetTailPNG("default", 20, 20, parse.HexColor("#cc00aa")) require.NoError(t, err) assertImg(t, img, 20, 20) } func TestGetHeadPNG(t *testing.T) { - img, err := GetHeadPNG("default", 20, 20) + img, err := GetHeadPNG("default", 20, 20, parse.HexColor("#cc00aa")) require.NoError(t, err) assertImg(t, img, 20, 20) } @@ -83,33 +86,42 @@ func TestSVGManager(t *testing.T) { require.NoError(t, mgr.writeFile("things/foo.svg", []byte(tailSVG))) require.DirExists(t, filepath.Join(baseDir, "things")) require.FileExists(t, mgr.getFullPath("things/foo.svg")) - err = mgr.ensureDownloaded("things/foo.svg") + customizedPath, err := mgr.ensureDownloaded("things/foo.svg", parse.HexColor("#cc00aa")) require.NoError(t, err) + require.Equal(t, "#cc00aa/things/foo.svg", customizedPath) require.NoError(t, mgr.ensureSubdirExists("some/subdir")) require.DirExists(t, mgr.getFullPath("some/subdir")) - img, err := mgr.loadSVGImage(headSVGPath("default"), 20, 20) + img, err := mgr.loadSnakeSVGImage(headSVGPath("default"), 20, 20, parse.HexColor("#cc00aa")) require.NoError(t, err) assertImg(t, img, 20, 20) } -func TestGetSVGImageWithFallback(t *testing.T) { +func TestGetSnakeSVGImage(t *testing.T) { - // this shouldn't require a fallback - img, err := getSVGImageWithFallback(tailSVGPath("default"), tailSVGPath("default"), 20, 20) + // these shouldn't require a fallback + img, err := getSnakeSVGImage(tailSVGPath("default"), "nofallback.png", 20, 20, parse.HexColor("#cc00aa")) + require.NoError(t, err) + require.NotNil(t, img) + assertImg(t, img, 20, 20) + img, err = getSnakeSVGImage(headSVGPath("default"), "nofallback.png", 20, 20, parse.HexColor("#cc00aa")) require.NoError(t, err) require.NotNil(t, img) assertImg(t, img, 20, 20) - // this should require a fallback - img, err = getSVGImageWithFallback(tailSVGPath("notfound"), tailSVGPath("default"), 20, 20) + // test head/tail fallbacks + img, err = getSnakeSVGImage(tailSVGPath("notfound"), fallbackTail, 20, 20, parse.HexColor("#cc00aa")) + require.NoError(t, err) + require.NotNil(t, img) + assertImg(t, img, 20, 20) + img, err = getSnakeSVGImage(headSVGPath("notfound"), fallbackHead, 20, 20, parse.HexColor("#cc00aa")) require.NoError(t, err) require.NotNil(t, img) assertImg(t, img, 20, 20) // this should just error - img, err = getSVGImageWithFallback(tailSVGPath("notfound"), tailSVGPath("notfound"), 20, 20) + img, err = getSnakeSVGImage(tailSVGPath("notfound"), "404/notfound.png", 20, 20, parse.HexColor("#cc00aa")) require.Error(t, err) require.Nil(t, img) } @@ -122,8 +134,8 @@ func TestGetWatermarkPNG(t *testing.T) { func assertImg(t *testing.T, img image.Image, w, h int) { require.NotNil(t, img) - assert.Equal(t, img.Bounds().Max.X, w) - assert.Equal(t, img.Bounds().Max.Y, h) + assert.Equal(t, w, img.Bounds().Max.X) + assert.Equal(t, h, img.Bounds().Max.Y) } func TestLoadImageFile(t *testing.T) { @@ -136,19 +148,24 @@ func TestLoadImageFile(t *testing.T) { require.Nil(t, i) } +func TestImageCacheKey(t *testing.T) { + require.Equal(t, "heads/default.png:20:20:", imageCacheKey(fallbackHead, 20, 20, nil)) // ensure nil color is okay + require.Equal(t, "hello:100:50:#00ccaa", imageCacheKey("hello", 100, 50, parse.HexColor("#0ca"))) // make sure color key works +} + func TestLoadLocalImageAsset(t *testing.T) { // happy paths for assets that should always exist - i, err := loadLocalImageAsset(fmt.Sprintf("assets/heads/%s.png", fallbackHeadID), 20, 20) + i, err := loadLocalImageAsset(fallbackHead, 20, 20) require.NoError(t, err) require.NotNil(t, i) // ensure caching works - _, ok := imageCache.Get(imageCacheKey(fmt.Sprintf("assets/heads/%s.png", fallbackTailID), 20, 20)) + _, ok := imageCache.Get(imageCacheKey(fallbackHead, 20, 20, nil)) require.True(t, ok, "image should get cached") - i, err = loadLocalImageAsset(fmt.Sprintf("assets/tails/%s.png", fallbackTailID), 20, 20) + i, err = loadLocalImageAsset(fallbackTail, 20, 20) require.NoError(t, err) require.NotNil(t, i) - i, err = loadLocalImageAsset("assets/watermark.png", 100, 100) + i, err = loadLocalImageAsset("watermark.png", 100, 100) require.NoError(t, err) require.NotNil(t, i) @@ -157,6 +174,17 @@ func TestLoadLocalImageAsset(t *testing.T) { require.Error(t, err, "this image doesnt exist, so it should error when loading") } +func TestChangeImageColor(t *testing.T) { + img, err := loadLocalImageAsset(fallbackHead, 50, 50) + require.NoError(t, err) + img = changeImageColor(img, color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + + want, err := loadImageFile("testdata/default_00ccaa.png") + require.NoError(t, err) + + imagetest.Equal(t, want, img) +} + func TestScaleImage(t *testing.T) { i, err := loadImageFile("assets/watermark.png") require.NoError(t, err) @@ -169,6 +197,37 @@ func TestScaleImage(t *testing.T) { assert.Equal(t, si.Bounds().Max.Y, newY) } +func TestColorToHex6(t *testing.T) { + require.Equal(t, "", colorToHex6(nil)) + require.Equal(t, "#00ccaa", colorToHex6(color.RGBA{0x00, 0xcc, 0xaa, 0xff})) + require.Equal(t, "#000000", colorToHex6(color.RGBA{0x00, 0x00, 0x00, 0x00})) + require.Equal(t, "#123456", colorToHex6(color.RGBA{0x12, 0x34, 0x56, 0x78})) + require.Equal(t, "#ffffff", colorToHex6(color.RGBA{0xff, 0xff, 0xff, 0xff})) +} + +func TestCustomiseSVG(t *testing.T) { + + // simple + customized := customiseSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + require.Equal(t, ``, customized) + + // make sure it doesn't panic with strange/bad inputs + customiseSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + customiseSnakeSVG("afe9*#@(#f2038208", color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + customiseSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + customiseSnakeSVG("<>>//", color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + customiseSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + + // nested + customized = customiseSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + require.Equal(t, ``, customized, "nested SVG tags should be ignored") + + // use a real head + customized = customiseSnakeSVG(headSVG, color.RGBA{0x00, 0xcc, 0xaa, 0xff}) + require.Contains(t, customized, ``) +} + const headSVG = ` diff --git a/media/testdata/default_00ccaa.png b/media/testdata/default_00ccaa.png new file mode 100644 index 0000000000000000000000000000000000000000..6f06c883f297310bb9936ef9a3a2ae186062dad7 GIT binary patch literal 977 zcmV;?11|iDP)p1jOI5G2H)AwqOOJtT)14?zql9t;XX6cj-XlF}R+TKYnV zHkbBLC_R*vLd#qVg&bN6J@kcA+J^L$cK0bW`OS8+-Q8@Gr_b|j$QJ^;v(L-#GrzZ) zNd;>Xc7e)y8<3XvK#Hb4kb)`M4Ir-SmMSfNsLAJ2!M#=!`_>wbtcvkH@>h;-D4MEb z-(6rZ_DCYeKL9QPE5HZ9hjK$AkVVi_z}bEi_!OnD29RLnf52ZTecO*(<9_c0=74t# z0uvyv0H34ub!TTqT;x2l52dFBpyUl!+TC;(YleU&;O(4Y0>qL)+{2n4XJ<*)TK9qX zB%B;TH3E7CrPr|LSKzk@JTHhvU<{>K1;X1JlKCP^KlHO5Ae@v=yy4cAy=R{1QI<+% z@*KoGpar5F+ZsSt>fzMvK}x%-wJHw`56?oOTDr;m1Es(9W9twbDro?DUuHjq)5dGo z$KH`ysb!QS;XUr}N0gqH$e^c`(bIO;xY;q(tI%mfO zN-y|)aN}XZTY$KmZU7&n^tBhRvSu2X$*5w5iE^R*6-uA>ZD75Ewp6HE!9dp~{{_wg zkA29w3IU&G3~@)2`a&lVF$j@6!{;YJuYAt}B9zx9w4=7!}beG&(xdnHX+CXB#Da3BudY1*62D_mLS1U*Pq5w`uKK| zx|Om9aUnE{(u;m6dnBxA`+yTz z^F~@Qd4yI)VEP5-^)z7eI(!Q-1Awbuuk92j&r`$z^GO;oc^`)cm?7Ypl;mx} z8d#{0ZUR$85F(gS;D9XgOYLBa3PJ=khBfoD>1qj6WDp{lZ?I;6JD8$_5W!4f&Ht@n zx&ed;W)f>=o56Go2m<4_#ouAgKaDWm1cG#Y2Q!5=e+!KNw@0^u5W)O_HGj(CMOT6# zk>mh=!kXWmXYx8}+^NC=p9`&@v8MVg00960a!6x}^!pES00000NkvXXu0mjfX2-qh literal 0 HcmV?d00001 diff --git a/parse/color.go b/parse/color.go new file mode 100644 index 0000000..9f0a6cc --- /dev/null +++ b/parse/color.go @@ -0,0 +1,31 @@ +package parse + +import ( + "fmt" + "image/color" + "strings" +) + +// From github.com/fogleman/gg +func HexColor(x string) color.Color { + var r, g, b, a uint8 + + x = strings.TrimPrefix(x, "#") + a = 255 + if len(x) == 3 { + format := "%1x%1x%1x" + fmt.Sscanf(x, format, &r, &g, &b) + r |= r << 4 + g |= g << 4 + b |= b << 4 + } + if len(x) == 6 { + format := "%02x%02x%02x" + fmt.Sscanf(x, format, &r, &g, &b) + } + if len(x) == 8 { + format := "%02x%02x%02x%02x" + fmt.Sscanf(x, format, &r, &g, &b, &a) + } + return color.RGBA{r, g, b, a} +} diff --git a/parse/color_test.go b/parse/color_test.go new file mode 100644 index 0000000..bec3c10 --- /dev/null +++ b/parse/color_test.go @@ -0,0 +1,31 @@ +package parse_test + +import ( + "image/color" + "testing" + + "github.com/BattlesnakeOfficial/exporter/parse" + "github.com/stretchr/testify/require" +) + +func TestHexColor(t *testing.T) { + c := parse.HexColor("#000") + require.Equal(t, color.RGBA{0x00, 0x00, 0x00, 0xff}, c) + c = parse.HexColor("000") + require.Equal(t, color.RGBA{0x00, 0x00, 0x00, 0xff}, c) + + c = parse.HexColor("#00CCAA") + require.Equal(t, color.RGBA{0x00, 0xcc, 0xaa, 0xff}, c) + c = parse.HexColor("00CCAA") + require.Equal(t, color.RGBA{0x00, 0xcc, 0xaa, 0xff}, c) + + c = parse.HexColor("#0aff1299") + require.Equal(t, color.RGBA{0x0a, 0xff, 0x12, 0x99}, c) + c = parse.HexColor("0aff1299") + require.Equal(t, color.RGBA{0x0a, 0xff, 0x12, 0x99}, c) + + c = parse.HexColor("") + require.Equal(t, color.RGBA{0x00, 0x00, 0x00, 0xff}, c) + c = parse.HexColor(")S*F)fj02fu82f") + require.Equal(t, color.RGBA{0x00, 0x00, 0x00, 0xff}, c) +} diff --git a/render/board.go b/render/board.go index 8062929..fc9d8e7 100644 --- a/render/board.go +++ b/render/board.go @@ -2,9 +2,11 @@ package render import ( "fmt" + "image/color" "strings" "github.com/BattlesnakeOfficial/exporter/engine" + "github.com/BattlesnakeOfficial/exporter/parse" log "github.com/sirupsen/logrus" ) @@ -54,7 +56,7 @@ type BoardSquareContentType int // Examples of content are food, snake body parts and hazard squares type BoardSquareContent struct { Type BoardSquareContentType - HexColor string + Color color.Color SnakeType string Direction snakeDirection Corner snakeCorner @@ -141,32 +143,32 @@ func (b *Board) addFood(p *engine.Point) { }) } -func (b *Board) addSnakeTail(p *engine.Point, color, snakeType string, direction snakeDirection) { +func (b *Board) addSnakeTail(p *engine.Point, c color.Color, snakeType string, direction snakeDirection) { // when a snake eats and grows, the tail is placed on the same square as a body // this makes sure we remove the body segment if that condition is hit b.removeIfExists(p.X, p.Y, BoardSquareSnakeBody) b.addContent(p, BoardSquareContent{ Type: BoardSquareSnakeTail, - HexColor: color, + Color: c, SnakeType: snakeType, Direction: direction, }) } -func (b *Board) addSnakeHead(p *engine.Point, color, snakeType string, dir snakeDirection) { +func (b *Board) addSnakeHead(p *engine.Point, c color.Color, snakeType string, dir snakeDirection) { b.addContent(p, BoardSquareContent{ Type: BoardSquareSnakeHead, - HexColor: color, + Color: c, SnakeType: snakeType, Direction: dir, }) } -func (b *Board) addSnakeBody(p *engine.Point, color string, dir snakeDirection, corner snakeCorner) { +func (b *Board) addSnakeBody(p *engine.Point, c color.Color, dir snakeDirection, corner snakeCorner) { b.addContent(p, BoardSquareContent{ Type: BoardSquareSnakeBody, - HexColor: color, + Color: c, Direction: dir, Corner: corner, }) @@ -295,9 +297,9 @@ func (b *Board) placeSnake(snake engine.Snake) { } // Death color - color := snake.Color + color := parse.HexColor(snake.Color) if snake.Death != nil { - color = ColorDeadSnake + color = parse.HexColor(ColorDeadSnake) } for i, point := range snake.Body { diff --git a/render/board_internal_test.go b/render/board_internal_test.go index 5d4621b..f9c0d67 100644 --- a/render/board_internal_test.go +++ b/render/board_internal_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/BattlesnakeOfficial/exporter/engine" + "github.com/BattlesnakeOfficial/exporter/parse" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -94,13 +95,13 @@ func TestBoard(t *testing.T) { } // ensure adding content works - b.addSnakeTail(&engine.Point{X: 0, Y: 0}, "#0acc33", "regular", movingRight) + b.addSnakeTail(&engine.Point{X: 0, Y: 0}, parse.HexColor("#0acc33"), "regular", movingRight) assert.Equal(t, BoardSquareSnakeTail, b.getContents(0, 0)[0].Type, "(0,0) should have tail content") - b.addSnakeBody(&engine.Point{X: 1, Y: 0}, "#0acc33", movingRight, "none") + b.addSnakeBody(&engine.Point{X: 1, Y: 0}, parse.HexColor("#0acc33"), movingRight, "none") assert.Equal(t, BoardSquareSnakeBody, b.getContents(1, 0)[0].Type, "(1,0) should have body content") - b.addSnakeHead(&engine.Point{X: 2, Y: 0}, "#0acc33", "regular", movingRight) + b.addSnakeHead(&engine.Point{X: 2, Y: 0}, parse.HexColor("#0acc33"), "regular", movingRight) assert.Equal(t, BoardSquareSnakeHead, b.getContents(2, 0)[0].Type, "(2,0) should have head content") b.addFood(&engine.Point{X: 3, Y: 0}) @@ -132,14 +133,14 @@ func TestPlaceSnake(t *testing.T) { require.Len(t, c, 1, "there should only be a head here") assert.Equal(t, BoardSquareSnakeHead, c[0].Type, "this should be a head") assert.Equal(t, movingDown, c[0].Direction, "the head should be pointing down") - assert.Equal(t, "#3B194D", c[0].HexColor, "the head should have the snake colour") + assert.Equal(t, parse.HexColor("#3B194D"), c[0].Color, "the head should have the snake colour") assert.Equal(t, "beluga", c[0].SnakeType, "the head should be customised") // BODY c = b.getContents(0, 1) require.Len(t, c, 1, "there should only be a body here") assert.Equal(t, BoardSquareSnakeBody, c[0].Type, "this should be a body") - assert.Equal(t, "#3B194D", c[0].HexColor, "the body should have the snake colour") + assert.Equal(t, parse.HexColor("#3B194D"), c[0].Color, "the body should have the snake colour") assert.Equal(t, "", c[0].SnakeType, "the body should not have a customization") // TAIL @@ -147,7 +148,7 @@ func TestPlaceSnake(t *testing.T) { require.Len(t, c, 1, "there should only be a tail here") assert.Equal(t, BoardSquareSnakeTail, c[0].Type, "this should be a tail") assert.Equal(t, movingRight, c[0].Direction, "the tail should be pointing right") - assert.Equal(t, "#3B194D", c[0].HexColor, "the tail should have the snake colour") + assert.Equal(t, parse.HexColor("#3B194D"), c[0].Color, "the tail should have the snake colour") assert.Equal(t, "rattle", c[0].SnakeType, "the tail should be customised") t.Log("Placing a dead snake") @@ -167,14 +168,14 @@ func TestPlaceSnake(t *testing.T) { require.Len(t, c, 1, "there should only be a head here") assert.Equal(t, BoardSquareSnakeHead, c[0].Type, "this should be a head") assert.Equal(t, movingUp, c[0].Direction, "the head should be pointing up") - assert.Equal(t, ColorDeadSnake, c[0].HexColor, "the head should have the dead snake colour") + assert.Equal(t, parse.HexColor(ColorDeadSnake), c[0].Color, "the head should have the dead snake colour") assert.Equal(t, "default", c[0].SnakeType, "the head should be default") // BODY c = b.getContents(5, 8) require.Len(t, c, 1, "there should only be a body here") assert.Equal(t, BoardSquareSnakeBody, c[0].Type, "this should be a body") - assert.Equal(t, ColorDeadSnake, c[0].HexColor, "the body should have the dead snake colour") + assert.Equal(t, parse.HexColor(ColorDeadSnake), c[0].Color, "the body should have the dead snake colour") assert.Equal(t, "", c[0].SnakeType, "the body should not have a customization") // TAIL @@ -182,7 +183,7 @@ func TestPlaceSnake(t *testing.T) { require.Len(t, c, 1, "there should only be a tail here") assert.Equal(t, BoardSquareSnakeTail, c[0].Type, "this should be a tail") assert.Equal(t, movingLeft, c[0].Direction, "the tail should be pointing left") - assert.Equal(t, ColorDeadSnake, c[0].HexColor, "the tail should have the dead snake colour") + assert.Equal(t, parse.HexColor(ColorDeadSnake), c[0].Color, "the tail should have the dead snake colour") assert.Equal(t, "default", c[0].SnakeType, "the tail should be default") } @@ -220,7 +221,7 @@ func TestRemoveIfExists(t *testing.T) { // ensure a non-matching type doesn't get removed require.Len(t, b.getContents(0, 0), 0) - b.addSnakeBody(&engine.Point{X: 0, Y: 0}, "", movingUp, cornerBottomLeft) + b.addSnakeBody(&engine.Point{X: 0, Y: 0}, nil, movingUp, cornerBottomLeft) require.Len(t, b.getContents(0, 0), 1) b.removeIfExists(0, 0, BoardSquareFood) require.Len(t, b.getContents(0, 0), 1) @@ -230,7 +231,7 @@ func TestRemoveIfExists(t *testing.T) { require.Len(t, b.getContents(0, 0), 0) // ensure that removal works okay when there is more than one content - b.addSnakeBody(&engine.Point{X: 0, Y: 0}, "", movingUp, cornerBottomLeft) + b.addSnakeBody(&engine.Point{X: 0, Y: 0}, nil, movingUp, cornerBottomLeft) b.addHazard(&engine.Point{X: 0, Y: 0}) require.Len(t, b.getContents(0, 0), 2) b.removeIfExists(0, 0, BoardSquareSnakeHead) diff --git a/render/gif.go b/render/gif.go index 4fc9951..e449d98 100644 --- a/render/gif.go +++ b/render/gif.go @@ -7,11 +7,11 @@ import ( "image/draw" "io" "runtime" - "sort" "time" "github.com/BattlesnakeOfficial/exporter/engine" "github.com/BattlesnakeOfficial/exporter/render/gif" + "github.com/ericpauley/go-quantize/quantize" log "github.com/sirupsen/logrus" ) @@ -28,7 +28,9 @@ func gameFrameToPalettedImage(g *engine.Game, gf *engine.GameFrame) *image.Palet // First, Board is rendered to RGBA Image // Second, RGBA Image converted to Paletted Image (lossy) rgbaImage := DrawBoard(board) - palettedImage := image.NewPaletted(rgbaImage.Bounds(), buildGIFPallete(rgbaImage)) + q := quantize.MedianCutQuantizer{} + p := q.Quantize(make([]color.Color, 0, 256), rgbaImage) + palettedImage := image.NewPaletted(rgbaImage.Bounds(), p) // No Dithering draw.Draw(palettedImage, rgbaImage.Bounds(), rgbaImage, image.Point{}, draw.Src) @@ -102,54 +104,3 @@ func recoverToError(panicArg interface{}) error { err = fmt.Errorf("panic at %s: %w", source, err) return err } - -// getColorCounts finds all unique colours in an image and returns a count -// of how often those colours are used, sorted in descending order. -func getColorCounts(img image.Image) usageList { - counts := map[color.Color]int{} - m := img.Bounds().Max - for x := 0; x < m.X; x++ { - for y := 0; y < m.Y; y++ { - counts[img.At(x, y)]++ - } - } - - l := make(usageList, len(counts)) - i := 0 - for k, v := range counts { - l[i] = colorUsage{k, v} - i++ - } - - sort.Sort(l) - - return l -} - -// buildGIFPallete builds a colour pallete that can be used to convert the given image to a GIF frame. -// Any image with any number of colours can be used. If the image has more colours than a GIF frame can -// support, the pallete will be a subset of the source image colours. -func buildGIFPallete(src image.Image) color.Palette { - counts := getColorCounts(src) - - pal := make(color.Palette, min(GIFMaxColorsPerFrame, len(counts))) - for i := 0; i < len(pal); i++ { - pal[i] = counts[i].Key - } - - return pal -} - -// colorUsage is simple pair of color and the number of times it's used -// It exists to make it easy to sort a slice of colors ordered by how much they are used. -type colorUsage struct { - Key color.Color - Value int -} - -// usageList is a type that is used to satisfy sort.Interface so we can sort colors by usage -type usageList []colorUsage - -func (p usageList) Len() int { return len(p) } -func (p usageList) Swap(i, j int) { p[i], p[j] = p[j], p[i] } -func (p usageList) Less(i, j int) bool { return p[i].Value > p[j].Value } // should be descending order diff --git a/render/gif_internal_test.go b/render/gif_internal_test.go deleted file mode 100644 index 8df4365..0000000 --- a/render/gif_internal_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package render - -import ( - "fmt" - "image" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetColorCounts(t *testing.T) { - cases := []struct { - name string - want usageList - }{ - { - name: "sample1.png", - want: usageList{ - { - Key: parseHexColor("#fff"), - Value: 400, - }, - }, - }, - { - name: "sample2.png", - want: usageList{ - { - Key: parseHexColor("#fff"), - Value: 300, - }, - { - Key: parseHexColor("#000"), - Value: 100, - }, - }, - }, - { - name: "sample3.png", - want: usageList{ - { - Key: parseHexColor("#fff"), - Value: 50, - }, - { - Key: parseHexColor("#000"), - Value: 100, - }, - { - Key: parseHexColor("#00ff00"), - Value: 50, - }, - { - Key: parseHexColor("#f00"), - Value: 100, - }, - { - Key: parseHexColor("#0011ff"), - Value: 100, - }, - }, - }, - } - - for _, tc := range cases { - assert.ElementsMatch(t, tc.want, getColorCounts(loadSample(tc.name)), tc.name) - } -} - -func TestBuildGIFPallete(t *testing.T) { - // How this test works... - // I made a special image that has more than 256 colours in it to ensure the pallete building caps it. - // I also made a square of black in the middle of the image which should be the most dominant colour by far. - // So this test should validate that the pallete caps max colours to the GIF limit and properly orders colours. - img := loadSample("sample4.png") - pallete := buildGIFPallete(img) - require.NotEmpty(t, pallete, "the pallete should not be empty") - assert.Len(t, pallete, GIFMaxColorsPerFrame, "the pallete should not be larger than a GIF can support") - require.Equal(t, parseHexColor("#000"), pallete[0], "the black square should be the most dominant colour and be first in the pallete") -} - -func loadSample(name string) image.Image { - f, err := os.Open(fmt.Sprintf("testdata/%s", name)) - if err != nil { - panic(err) - } - defer f.Close() - - assetImage, _, err := image.Decode(f) - if err != nil { - panic(err) - } - - return assetImage -} diff --git a/render/gif_test.go b/render/gif_test.go new file mode 100644 index 0000000..e6ce09c --- /dev/null +++ b/render/gif_test.go @@ -0,0 +1,525 @@ +package render_test + +import ( + "bytes" + "encoding/json" + "image/gif" + "os" + "testing" + + "github.com/BattlesnakeOfficial/exporter/engine" + "github.com/BattlesnakeOfficial/exporter/imagetest" + "github.com/BattlesnakeOfficial/exporter/render" + "github.com/stretchr/testify/require" +) + +func TestHappyPath(t *testing.T) { + + // Note: there is a known issue with the watermark not loading because the path is wrong when the test executes. + + const goldenFilePath = "testdata/TestHappyPath_golden.gif" + // Generate the golden file (this shouldn't be done unless tests fail because of intentional rendering changes) + // generateGoldenFile(t, goldenFilePath) // uncomment to regenerate golden file + + f, err := os.Open(goldenFilePath) + require.NoError(t, err) + defer f.Close() + snapshot, err := gif.Decode(f) + require.NoError(t, err) + + var buf bytes.Buffer + game, frame := loadState(t) + + err = render.GameFrameToGIF(&buf, game, frame) + require.NoError(t, err) + current, err := gif.Decode(&buf) + require.NoError(t, err) + imagetest.Equal(t, snapshot, current) +} + +// generates the golden file, uncomment to regenerate +// nolint: unused,deadcode +func generateGoldenFile(t *testing.T, name string) { + game, frame := loadState(t) + f, err := os.Create(name) + require.NoError(t, err) + defer f.Close() + err = render.GameFrameToGIF(f, game, frame) + require.NoError(t, err) +} + +func loadState(t *testing.T) (*engine.Game, *engine.GameFrame) { + var game engine.Game + err := json.Unmarshal([]byte(gameJSON), &game) + require.NoError(t, err) + var frame engine.GameFrame + err = json.Unmarshal([]byte(frameJSON), &frame) + require.NoError(t, err) + return &game, &frame +} + +const frameJSON = `{ + "Turn": 150, + "Food": [ + { + "X": 7, + "Y": 1 + }, + { + "X": 2, + "Y": 10 + } + ], + "Snakes": [ + { + "ID": "gs_1", + "Name": "Snake 1", + "Body": [ + { + "X": 9, + "Y": 3 + }, + { + "X": 9, + "Y": 4 + }, + { + "X": 10, + "Y": 4 + }, + { + "X": 10, + "Y": 5 + }, + { + "X": 10, + "Y": 6 + }, + { + "X": 9, + "Y": 6 + }, + { + "X": 9, + "Y": 5 + }, + { + "X": 8, + "Y": 5 + }, + { + "X": 8, + "Y": 6 + }, + { + "X": 8, + "Y": 7 + }, + { + "X": 7, + "Y": 7 + }, + { + "X": 7, + "Y": 6 + } + ], + "Health": 53, + "Death": null, + "Color": "#00aacc", + "HeadType": "crystal-power", + "TailType": "crystal-power" + }, + { + "ID": "gs_2", + "Name": "snake 2", + "Body": [ + { + "X": 6, + "Y": 6 + }, + { + "X": 6, + "Y": 5 + }, + { + "X": 5, + "Y": 5 + }, + { + "X": 5, + "Y": 4 + }, + { + "X": 6, + "Y": 4 + }, + { + "X": 7, + "Y": 4 + }, + { + "X": 8, + "Y": 4 + }, + { + "X": 8, + "Y": 3 + }, + { + "X": 7, + "Y": 3 + }, + { + "X": 6, + "Y": 3 + }, + { + "X": 5, + "Y": 3 + }, + { + "X": 5, + "Y": 2 + }, + { + "X": 6, + "Y": 2 + }, + { + "X": 6, + "Y": 1 + } + ], + "Health": 76, + "Death": null, + "Color": "#ff7043", + "HeadType": "trans-rights-scarf", + "TailType": "rocket" + }, + { + "ID": "gs_3", + "Name": "snake 3", + "Body": [ + { + "X": 5, + "Y": 8 + }, + { + "X": 5, + "Y": 7 + }, + { + "X": 5, + "Y": 6 + }, + { + "X": 5, + "Y": 5 + } + ], + "Health": 97, + "Death": { + "Cause": "head-collision", + "Turn": 7 + }, + "Color": "#c91f37", + "HeadType": "gamer", + "TailType": "coffee" + }, + { + "ID": "gs_4", + "Name": "Snake 4", + "Body": [ + { + "X": 2, + "Y": 6 + }, + { + "X": 1, + "Y": 6 + }, + { + "X": 0, + "Y": 6 + }, + { + "X": 0, + "Y": 5 + }, + { + "X": 0, + "Y": 4 + }, + { + "X": 1, + "Y": 4 + }, + { + "X": 2, + "Y": 4 + }, + { + "X": 3, + "Y": 4 + }, + { + "X": 3, + "Y": 5 + }, + { + "X": 4, + "Y": 5 + }, + { + "X": 4, + "Y": 6 + }, + { + "X": 4, + "Y": 7 + } + ], + "Health": 54, + "Death": null, + "Color": "#ff9900", + "HeadType": "tiger-king", + "TailType": "crystal-power" + } + ], + "Hazards": [ + { + "X": 0, + "Y": 0 + }, + { + "X": 0, + "Y": 1 + }, + { + "X": 0, + "Y": 2 + }, + { + "X": 0, + "Y": 3 + }, + { + "X": 0, + "Y": 4 + }, + { + "X": 0, + "Y": 5 + }, + { + "X": 0, + "Y": 6 + }, + { + "X": 0, + "Y": 7 + }, + { + "X": 0, + "Y": 8 + }, + { + "X": 0, + "Y": 9 + }, + { + "X": 0, + "Y": 10 + }, + { + "X": 1, + "Y": 0 + }, + { + "X": 1, + "Y": 1 + }, + { + "X": 1, + "Y": 2 + }, + { + "X": 1, + "Y": 3 + }, + { + "X": 1, + "Y": 4 + }, + { + "X": 1, + "Y": 5 + }, + { + "X": 1, + "Y": 6 + }, + { + "X": 1, + "Y": 7 + }, + { + "X": 1, + "Y": 8 + }, + { + "X": 1, + "Y": 9 + }, + { + "X": 1, + "Y": 10 + }, + { + "X": 2, + "Y": 0 + }, + { + "X": 2, + "Y": 1 + }, + { + "X": 2, + "Y": 9 + }, + { + "X": 2, + "Y": 10 + }, + { + "X": 3, + "Y": 0 + }, + { + "X": 3, + "Y": 1 + }, + { + "X": 3, + "Y": 9 + }, + { + "X": 3, + "Y": 10 + }, + { + "X": 4, + "Y": 0 + }, + { + "X": 4, + "Y": 1 + }, + { + "X": 4, + "Y": 9 + }, + { + "X": 4, + "Y": 10 + }, + { + "X": 5, + "Y": 0 + }, + { + "X": 5, + "Y": 1 + }, + { + "X": 5, + "Y": 9 + }, + { + "X": 5, + "Y": 10 + }, + { + "X": 6, + "Y": 0 + }, + { + "X": 6, + "Y": 1 + }, + { + "X": 6, + "Y": 9 + }, + { + "X": 6, + "Y": 10 + }, + { + "X": 7, + "Y": 0 + }, + { + "X": 7, + "Y": 1 + }, + { + "X": 7, + "Y": 9 + }, + { + "X": 7, + "Y": 10 + }, + { + "X": 8, + "Y": 0 + }, + { + "X": 8, + "Y": 1 + }, + { + "X": 8, + "Y": 9 + }, + { + "X": 8, + "Y": 10 + }, + { + "X": 9, + "Y": 0 + }, + { + "X": 9, + "Y": 1 + }, + { + "X": 9, + "Y": 9 + }, + { + "X": 9, + "Y": 10 + }, + { + "X": 10, + "Y": 0 + }, + { + "X": 10, + "Y": 1 + }, + { + "X": 10, + "Y": 9 + }, + { + "X": 10, + "Y": 10 + } + ] +}` +const gameJSON = `{"ID":"test123","Status":"complete","Width":11,"Height":11}` diff --git a/render/image.go b/render/image.go index 6b3b580..610484a 100644 --- a/render/image.go +++ b/render/image.go @@ -4,8 +4,6 @@ import ( "fmt" "image" "image/color" - "image/draw" - "strings" "time" "github.com/BattlesnakeOfficial/exporter/media" @@ -44,30 +42,6 @@ const ( // cache for storing image.Image objects to speed up rendering var imageCache = cache.New(6*time.Hour, 10*time.Minute) -// From github.com/fogleman/gg -func parseHexColor(x string) color.Color { - var r, g, b, a uint8 - - x = strings.TrimPrefix(x, "#") - a = 255 - if len(x) == 3 { - format := "%1x%1x%1x" - fmt.Sscanf(x, format, &r, &g, &b) - r |= r << 4 - g |= g << 4 - b |= b << 4 - } - if len(x) == 6 { - format := "%02x%02x%02x" - fmt.Sscanf(x, format, &r, &g, &b) - } - if len(x) == 8 { - format := "%02x%02x%02x%02x" - fmt.Sscanf(x, format, &r, &g, &b, &a) - } - return color.RGBA{r, g, b, a} -} - func rotateImage(src image.Image, rot rotations) image.Image { switch rot { case rotate90: @@ -121,7 +95,7 @@ func drawHazard(dc *gg.Context, bx int, by int) { dc.Fill() } -func drawSnakeImage(name string, st snakeImageType, dc *gg.Context, bx int, by int, hexColor string, dir snakeDirection) { +func drawSnakeImage(name string, st snakeImageType, dc *gg.Context, bx int, by int, c color.Color, dir snakeDirection) { width := SquareSizePixels - SquareBorderPixels*2 height := SquareSizePixels - SquareBorderPixels*2 @@ -130,9 +104,9 @@ func drawSnakeImage(name string, st snakeImageType, dc *gg.Context, bx int, by i var err error switch st { case snakeHead: - snakeImg, err = media.GetHeadPNG(name, width, height) + snakeImg, err = media.GetHeadPNG(name, width, height, c) case snakeTail: - snakeImg, err = media.GetTailPNG(name, width, height) + snakeImg, err = media.GetTailPNG(name, width, height, c) default: log.WithField("snakeImageType", st).Error("unable to draw an unrecognized snake image type") } @@ -155,21 +129,13 @@ func drawSnakeImage(name string, st snakeImageType, dc *gg.Context, bx int, by i } snakeImg = rotateImage(snakeImg, rot) - dst := dc.Image().(draw.Image) - dstRect := image.Rect( - int(boardXToDrawX(dc, bx))+SquareBorderPixels+BoardBorder, - int(boardYToDrawY(dc, by))+SquareBorderPixels+BoardBorder, - int(boardXToDrawX(dc, bx+1))-SquareBorderPixels+BoardBorder, - int(boardYToDrawY(dc, by-1))-SquareBorderPixels+BoardBorder, - ) - - srcImage := &image.Uniform{parseHexColor(hexColor)} - - draw.DrawMask(dst, dstRect, srcImage, image.Point{}, snakeImg, image.Point{}, draw.Over) + dx := int(boardXToDrawX(dc, bx)) + SquareBorderPixels + BoardBorder + dy := int(boardYToDrawY(dc, by)) + SquareBorderPixels + BoardBorder + dc.DrawImage(snakeImg, dx, dy) } -func drawSnakeBody(dc *gg.Context, bx int, by int, hexColor string, corner snakeCorner) { - dc.SetHexColor(hexColor) +func drawSnakeBody(dc *gg.Context, bx int, by int, c color.Color, corner snakeCorner) { + dc.SetColor(c) if corner == "none" { dc.DrawRectangle( boardXToDrawX(dc, bx)+SquareBorderPixels+BoardBorder, @@ -237,8 +203,8 @@ func drawSnakeBody(dc *gg.Context, bx int, by int, hexColor string, corner snake dc.Fill() } -func drawGaps(dc *gg.Context, bx, by int, dir snakeDirection, hexColor string) { - dc.SetHexColor(hexColor) +func drawGaps(dc *gg.Context, bx, by int, dir snakeDirection, c color.Color) { + dc.SetColor(c) switch dir { case movingUp: dc.DrawRectangle( @@ -315,13 +281,13 @@ func DrawBoard(b *Board) image.Image { for _, c := range s.Contents { switch c.Type { case BoardSquareSnakeHead: - drawSnakeImage(c.SnakeType, snakeHead, dc, p.X, p.Y, c.HexColor, c.Direction) - drawGaps(dc, p.X, p.Y, c.Direction, c.HexColor) + drawSnakeImage(c.SnakeType, snakeHead, dc, p.X, p.Y, c.Color, c.Direction) + drawGaps(dc, p.X, p.Y, c.Direction, c.Color) case BoardSquareSnakeBody: - drawSnakeBody(dc, p.X, p.Y, c.HexColor, c.Corner) - drawGaps(dc, p.X, p.Y, c.Direction, c.HexColor) + drawSnakeBody(dc, p.X, p.Y, c.Color, c.Corner) + drawGaps(dc, p.X, p.Y, c.Direction, c.Color) case BoardSquareSnakeTail: - drawSnakeImage(c.SnakeType, snakeTail, dc, p.X, p.Y, c.HexColor, c.Direction) + drawSnakeImage(c.SnakeType, snakeTail, dc, p.X, p.Y, c.Color, c.Direction) case BoardSquareFood: drawFood(dc, p.X, p.Y) case BoardSquareHazard: diff --git a/render/testdata/TestHappyPath_golden.gif b/render/testdata/TestHappyPath_golden.gif new file mode 100644 index 0000000000000000000000000000000000000000..af16a940ea2a7b1d7c823182daa8e0399dd9e294 GIT binary patch literal 6051 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 literal 0 HcmV?d00001