doodle/pkg/level/chunker_test.go
Noah Petherbridge 5654145fd8 (Experimental) Run Length Encoding for Levels
Finally add a second option for Chunk MapAccessor implementation besides the
MapAccessor. The RLEAccessor is basically a MapAccessor that will compress
your drawing with Run Length Encoding (RLE) in the on-disk format in the ZIP
file.

This slashes the file sizes of most levels:

* Shapeshifter: 21.8 MB -> 8.1 MB
* Jungle: 10.4 MB -> 4.1 MB
* Zoo: 2.8 MB -> 1.3 MB

Implementation details:

* The RLE binary format for Chunks is a stream of Uvarint pairs storing the
  palette index number and the number of pixels to repeat it (along the Y,X
  axis of the chunk).
    * Null colors are represented by a Uvarint that decodes to 0xFFFF
      or 65535 in decimal.
    * Gameplay logic currently limits maps to 256 colors.
* The default for newly created chunks in-game will be RLE by default.
* Its in-memory representation is still a MapAccessor (a map of absolute
  world coordinates to palette index).
* The game can still open and play legacy MapAccessor maps.
* On save in the editor, the game will upgrade/convert MapAccessor chunks over
  to RLEAccessors, improving on your level's file size with a simple re-save.

Current Bugs

* On every re-save to RLE, one pixel is lost in the bottom-right corner of
  each chunk. Each subsequent re-save loses one more pixel to the left, so what
  starts as a single pixel per chunk slowly evolves into a horizontal line.
* Some pixels smear vertically as well.
* Off-by-negative-one errors when some chunks Iter() their pixels but compute
  a relative coordinate of (-1,0)! Some mismatch between the stored world coords
  of a pixel inside the chunk vs. the chunk's assigned coordinate by the Chunker:
  certain combinations of chunk coord/abs coord.

To Do

* The `doodad touch` command should re-save existing levels to upgrade them.
2024-05-23 23:02:01 -07:00

326 lines
7.1 KiB
Go

package level_test
import (
"fmt"
"testing"
"git.kirsle.net/SketchyMaze/doodle/pkg/level"
"git.kirsle.net/go/render"
)
func TestWorldSize(t *testing.T) {
type TestCase struct {
Size uint8
Points []render.Point
Expect render.Rect
Zero render.Rect // expected WorldSizePositive
}
var tests = []TestCase{
{
Size: 200,
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)
}
}
}
func TestViewportChunks(t *testing.T) {
// Initialize a 100 chunk image with 5x5 chunks.
var ChunkSize uint8 = 100
var Offset int = 50
c := level.NewChunker(ChunkSize)
sw := &level.Swatch{
Name: "solid",
Color: render.Black,
}
// The 5x5 chunks are expected to be (diagonally)
// -2,-2
// -1,-1
// 0,0
// 1,1
// 2,2
// The chunk size is 100px so place a single pixel in each
// 100px quadrant.
fmt.Printf("size=%d offset=%d\n", ChunkSize, Offset)
for x := -2; x <= 2; x++ {
for y := -2; y <= 2; y++ {
point := render.NewPoint(
x*int(ChunkSize)+Offset,
y*int(ChunkSize)+Offset,
)
fmt.Printf("in chunk: %d,%d set pt: %s\n",
x, y, point,
)
c.Set(point, sw)
}
}
// Sanity check the test canvas was created correctly.
worldSize := c.WorldSize()
expectSize := render.Rect{
X: -200,
Y: -200,
W: 299,
H: 299,
}
if worldSize != expectSize {
t.Errorf(
"Test canvas world size wasn't as expected:\n"+
"Expected: %s\n"+
" Actual: %s\n",
expectSize,
worldSize,
)
}
if len(c.Chunks) != 25 {
t.Errorf(
"Test canvas chunk count wasn't as expected:\n"+
"Expected: 25\n"+
" Actual: %d\n",
len(c.Chunks),
)
}
type TestCase struct {
Viewport render.Rect
Expect map[render.Point]interface{}
}
var tests = []TestCase{
{
Viewport: render.Rect{X: -10000, Y: -10000, W: 10000, H: 10000},
Expect: map[render.Point]interface{}{
render.NewPoint(-2, -2): nil,
render.NewPoint(-2, -1): nil,
render.NewPoint(-2, 0): nil,
render.NewPoint(-2, 1): nil,
render.NewPoint(-2, 2): nil,
render.NewPoint(-1, -2): nil,
render.NewPoint(-1, -1): nil,
render.NewPoint(-1, 0): nil,
render.NewPoint(-1, 1): nil,
render.NewPoint(-1, 2): nil,
render.NewPoint(0, -2): nil,
render.NewPoint(0, -1): nil,
render.NewPoint(0, 0): nil,
render.NewPoint(0, 1): nil,
render.NewPoint(0, 2): nil,
render.NewPoint(1, -2): nil,
render.NewPoint(1, -1): nil,
render.NewPoint(1, 0): nil,
render.NewPoint(1, 1): nil,
render.NewPoint(1, 2): nil,
render.NewPoint(2, -2): nil,
render.NewPoint(2, -1): nil,
render.NewPoint(2, 0): nil,
render.NewPoint(2, 1): nil,
render.NewPoint(2, 2): nil,
},
},
{
Viewport: render.Rect{X: 0, Y: 0, W: 200, H: 200},
Expect: map[render.Point]interface{}{
render.NewPoint(0, 0): nil,
render.NewPoint(0, 1): nil,
render.NewPoint(1, 0): nil,
render.NewPoint(1, 1): nil,
},
},
// {
// Viewport: render.Rect{X: -5, Y: 0, W: 200, H: 200},
// Expect: map[render.Point]interface{}{
// render.NewPoint(-1, 0): nil,
// render.NewPoint(0, 0): nil,
// render.NewPoint(1, 1): nil,
// },
// },
}
for _, test := range tests {
chunks := []render.Point{}
for chunk := range c.IterViewportChunks(test.Viewport) {
chunks = append(chunks, chunk)
}
if len(chunks) != len(test.Expect) {
t.Errorf("%s: chunk count mismatch: expected %d, got %d",
test.Viewport,
len(test.Expect),
len(chunks),
)
}
for _, actual := range chunks {
if _, ok := test.Expect[actual]; !ok {
t.Errorf("%s: got chunk coord %d but did not expect to",
test.Viewport,
actual,
)
}
delete(test.Expect, actual)
}
if len(test.Expect) > 0 {
t.Errorf("%s: failed to see these coords: %+v",
test.Viewport,
test.Expect,
)
}
}
}
func TestRelativeCoordinates(t *testing.T) {
var (
chunker = level.NewChunker(128)
)
type TestCase struct {
WorldCoord render.Point
ChunkCoord render.Point
ExpectRelative render.Point
}
var tests = []TestCase{
{
WorldCoord: render.NewPoint(4, 8),
ExpectRelative: render.NewPoint(4, 8),
},
{
WorldCoord: render.NewPoint(128, 128),
ExpectRelative: render.NewPoint(0, 0),
},
{
WorldCoord: render.NewPoint(143, 144),
ExpectRelative: render.NewPoint(15, 16),
},
{
WorldCoord: render.NewPoint(-105, -86),
ExpectRelative: render.NewPoint(23, 42),
},
{
WorldCoord: render.NewPoint(-252, 264),
ExpectRelative: render.NewPoint(4, 8),
},
// These were seen breaking actual levels, at the corners of the chunk
{
WorldCoord: render.NewPoint(511, 256),
ExpectRelative: render.NewPoint(127, 0), // was getting -1,0 in game
},
{
WorldCoord: render.NewPoint(511, 512),
ChunkCoord: render.NewPoint(4, 4),
ExpectRelative: render.NewPoint(127, 0), // was getting -1,0 in game
},
{
WorldCoord: render.NewPoint(127, 384),
ChunkCoord: render.NewPoint(1, 3),
ExpectRelative: render.NewPoint(-1, 0),
},
}
for i, test := range tests {
var (
chunkCoord = test.ChunkCoord
actualRelative = level.RelativeCoordinate(
test.WorldCoord,
chunkCoord,
chunker.Size,
)
roundTrip = level.FromRelativeCoordinate(
actualRelative,
chunkCoord,
chunker.Size,
)
)
// compute expected chunk coord automatically?
if chunkCoord == render.Origin {
chunkCoord = chunker.ChunkCoordinate(test.WorldCoord)
}
if actualRelative != test.ExpectRelative {
t.Errorf("Test %d: world coord %s in chunk %s\n"+
"Expected RelativeCoordinate() to be: %s\n"+
"But it was: %s",
i,
test.WorldCoord,
chunkCoord,
test.ExpectRelative,
actualRelative,
)
}
if roundTrip != test.WorldCoord {
t.Errorf("Test %d: world coord %s in chunk %s\n"+
"Did not survive round trip! Expected: %s\n"+
"But it was: %s",
i,
test.WorldCoord,
chunkCoord,
test.WorldCoord,
roundTrip,
)
}
}
}