diff --git a/pkg/atlas/atlas.go b/pkg/atlas/atlas.go index 6ec11e2..ef25d9c 100644 --- a/pkg/atlas/atlas.go +++ b/pkg/atlas/atlas.go @@ -38,11 +38,11 @@ type Atlas struct { // This is intentionally not a 2D array so it can be expanded in all directions Chunks []Chunk `json:"chunks"` - // CurrentSize is the current width/height of the atlas in chunks - CurrentSize vector.Vector `json:"currentSize"` + // LowerBound is the origin of the bottom left corner of the current chunks in world space (current chunks cover >= this value) + LowerBound vector.Vector `json:"lowerBound"` - // WorldOrigin represents the location of the [0,0] world space point in terms of the allotted current chunks - WorldOrigin vector.Vector `json:"worldOrigin"` + // UpperBound is the top left corner of the current chunks (curent chunks cover < this value) + UpperBound vector.Vector `json:"upperBound"` // ChunkSize is the x/y dimensions of each square chunk ChunkSize int `json:"chunksize"` @@ -52,10 +52,10 @@ type Atlas struct { func NewAtlas(chunkSize int) Atlas { // Start up with one chunk a := Atlas{ - ChunkSize: chunkSize, - Chunks: make([]Chunk, 1), - CurrentSize: vector.Vector{X: 1, Y: 1}, - WorldOrigin: vector.Vector{X: 0, Y: 0}, + ChunkSize: chunkSize, + Chunks: make([]Chunk, 1), + LowerBound: vector.Vector{X: 0, Y: 0}, + UpperBound: vector.Vector{X: chunkSize, Y: chunkSize}, } // Initialise the first chunk a.Chunks[0].populate(chunkSize) @@ -162,129 +162,88 @@ func (a *Atlas) worldSpaceToChunkLocal(v vector.Vector) vector.Vector { return vector.Vector{X: maths.Pmod(v.X, a.ChunkSize), Y: maths.Pmod(v.Y, a.ChunkSize)} } -// worldSpaceToChunk gets the current chunk ID for a position in the world -func (a *Atlas) worldSpaceToChunk(v vector.Vector) int { - // First convert to chunk space - chunkSpace := a.worldSpaceToChunkSpace(v) +// worldSpaceToChunkID gets the current chunk ID for a position in the world +func (a *Atlas) worldSpaceToChunkIndex(v vector.Vector) int { + // Shift the vector by our current min + v = v.Added(a.LowerBound.Negated()) - // Then return the ID - return a.chunkSpaceToChunk(chunkSpace) + // Divide by the current size and floor, to get chunk-scaled vector from the lower bound + v = v.DividedFloor(a.ChunkSize) + + // Calculate the width + width := a.UpperBound.X - a.LowerBound.X + widthInChunks := width / a.ChunkSize + + // Along the corridor and up the stairs + return (v.Y * widthInChunks) + v.X } -// worldSpaceToChunkSpace converts from world space to chunk space -func (a *Atlas) worldSpaceToChunkSpace(v vector.Vector) vector.Vector { - // Remove the chunk local part - chunkOrigin := v.Added(a.worldSpaceToChunkLocal(v).Negated()) - // Convert to chunk space coordinate - chunkSpaceOrigin := chunkOrigin.Divided(a.ChunkSize) - // Shift it by our current chunk origin - chunkIndexOrigin := chunkSpaceOrigin.Added(a.WorldOrigin) - - return chunkIndexOrigin -} - -// chunkSpaceToWorldSpace vonverts from chunk space to world space -func (a *Atlas) chunkSpaceToWorldSpace(v vector.Vector) vector.Vector { - - // Shift it by the current chunk origin - shifted := v.Added(a.WorldOrigin.Negated()) - - // Multiply out by chunk size - return shifted.Multiplied(a.ChunkSize) -} - -// chunkOriginInChunkSpace Gets the chunk origin in chunk space -func (a *Atlas) chunkOriginInChunkSpace(chunk int) vector.Vector { - // convert the chunk to chunk space - chunkOrigin := a.chunkToChunkSpace(chunk) - - // Shift it by the current chunk origin - return chunkOrigin.Added(a.WorldOrigin.Negated()) -} - -// chunkOriginInWorldSpace gets the chunk origin for a given chunk index +// chunkOriginInWorldSpace returns the origin of the chunk in world space func (a *Atlas) chunkOriginInWorldSpace(chunk int) vector.Vector { - // convert the chunk to chunk space - chunkSpace := a.chunkToChunkSpace(chunk) + // Calculate the width + width := a.UpperBound.X - a.LowerBound.X + widthInChunks := width / a.ChunkSize - // Convert to world space - return a.chunkSpaceToWorldSpace(chunkSpace) -} - -// chunkSpaceToChunk converts from chunk space to the chunk -func (a *Atlas) chunkSpaceToChunk(v vector.Vector) int { - // Along the coridor and up the stair - return (v.Y * a.CurrentSize.X) + v.X -} - -// chunkToChunkSpace returns the chunk space coord for the chunk -func (a *Atlas) chunkToChunkSpace(chunk int) vector.Vector { - return vector.Vector{ - X: maths.Pmod(chunk, a.CurrentSize.Y), - Y: (chunk / a.CurrentSize.X), + // Reverse the along the corridor and up the stairs + v := vector.Vector{ + X: chunk % widthInChunks, + Y: chunk / widthInChunks, } + // Multiply up to world scale + v = v.Multiplied(a.ChunkSize) + // Shift by the lower bound + return v.Added(a.LowerBound) } -func (a *Atlas) getExtents() (min vector.Vector, max vector.Vector) { - min = a.WorldOrigin.Negated() - max = min.Added(a.CurrentSize) +// getNewBounds gets new lower and upper bounds for the world space given a vector +func (a *Atlas) getNewBounds(v vector.Vector) (lower vector.Vector, upper vector.Vector) { + lower = vector.Min(v, a.LowerBound) + upper = vector.Max(v.Added(vector.Vector{X: 1, Y: 1}), a.UpperBound) + + lower = vector.Vector{ + X: maths.RoundDown(lower.X, a.ChunkSize), + Y: maths.RoundDown(lower.Y, a.ChunkSize), + } + upper = vector.Vector{ + X: maths.RoundUp(upper.X, a.ChunkSize), + Y: maths.RoundUp(upper.Y, a.ChunkSize), + } return } // worldSpaceToTrunkWithGrow will expand the current atlas for a given world space position if needed func (a *Atlas) worldSpaceToChunkWithGrow(v vector.Vector) int { - min, max := a.getExtents() - - // Divide by the chunk size to bring into chunk space - v = v.Divided(a.ChunkSize) - - // Check we're within the current extents and bail early - if v.X >= min.X && v.Y >= min.Y && v.X < max.X && v.Y < max.Y { - return a.worldSpaceToChunk(v) + // If we're within bounds, just return the current chunk + if v.X >= a.LowerBound.X && v.Y >= a.LowerBound.Y && v.X < a.UpperBound.X && v.Y < a.UpperBound.Y { + return a.worldSpaceToChunkIndex(v) } - // Calculate the new origin and the new size - origin := min - size := a.CurrentSize + // Calculate the new bounds + lower, upper := a.getNewBounds(v) + size := upper.Added(lower.Negated()) + size = size.Divided(a.ChunkSize) - // If we need to shift the origin back - originDiff := origin.Added(v.Negated()) - if originDiff.X > 0 { - origin.X -= originDiff.X - size.X += originDiff.X - } - if originDiff.Y > 0 { - origin.Y -= originDiff.Y - size.Y += originDiff.Y - } - - // If we need to expand the size - maxDiff := v.Added(max.Negated()) - if maxDiff.X > 0 { - size.X += maxDiff.X - } - if maxDiff.Y > 0 { - size.Y += maxDiff.Y - } - - // Set up the new size and origin + // Create the new empty atlas newAtlas := Atlas{ - ChunkSize: a.ChunkSize, - WorldOrigin: origin.Negated(), - CurrentSize: size, - Chunks: make([]Chunk, size.X*size.Y), + ChunkSize: a.ChunkSize, + LowerBound: lower, + UpperBound: upper, + Chunks: make([]Chunk, size.X*size.Y), } // Copy all old chunks into the new atlas for chunk, chunkData := range a.Chunks { - // Calculate the new chunk location and copy over the data - newChunk := newAtlas.worldSpaceToChunk(a.chunkOriginInWorldSpace(chunk)) + + // Calculate the chunk ID in the new atlas + origin := a.chunkOriginInWorldSpace(chunk) + newChunk := newAtlas.worldSpaceToChunkIndex(origin) + // Copy over the old chunk to the new atlas newAtlas.Chunks[newChunk] = chunkData } - // Copy the new atlas data into this one + // Overwrite the old atlas with this one *a = newAtlas - return a.worldSpaceToChunk(v) + return a.worldSpaceToChunkIndex(v) } diff --git a/pkg/atlas/atlas_test.go b/pkg/atlas/atlas_test.go index 9778150..60c3922 100644 --- a/pkg/atlas/atlas_test.go +++ b/pkg/atlas/atlas_test.go @@ -22,43 +22,49 @@ func TestAtlas_toChunk(t *testing.T) { // Get a tile to spawn the chunks a.QueryPosition(vector.Vector{X: -1, Y: -1}) a.QueryPosition(vector.Vector{X: 0, Y: 0}) + assert.Equal(t, 2*2, len(a.Chunks)) // Chunks should look like: // 2 | 3 // ----- // 0 | 1 - chunkID := a.worldSpaceToChunk(vector.Vector{X: 0, Y: 0}) + chunkID := a.worldSpaceToChunkIndex(vector.Vector{X: 0, Y: 0}) assert.Equal(t, 3, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: 0, Y: -1}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 0, Y: -1}) assert.Equal(t, 1, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: -1, Y: -1}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: -1, Y: -1}) assert.Equal(t, 0, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: -1, Y: 0}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: -1, Y: 0}) assert.Equal(t, 2, chunkID) a = NewAtlas(2) assert.NotNil(t, a) // Get a tile to spawn the chunks a.QueryPosition(vector.Vector{X: -2, Y: -2}) + assert.Equal(t, 2*2, len(a.Chunks)) a.QueryPosition(vector.Vector{X: 1, Y: 1}) + assert.Equal(t, 2*2, len(a.Chunks)) // Chunks should look like: // 2 | 3 // ----- // 0 | 1 - chunkID = a.worldSpaceToChunk(vector.Vector{X: 1, Y: 1}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 1, Y: 1}) assert.Equal(t, 3, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: 1, Y: -2}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 1, Y: -2}) assert.Equal(t, 1, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: -2, Y: -2}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: -2, Y: -2}) assert.Equal(t, 0, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: -2, Y: 1}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: -2, Y: 1}) assert.Equal(t, 2, chunkID) a = NewAtlas(2) assert.NotNil(t, a) - // Get a tile to spawn the chunks - a.QueryPosition(vector.Vector{X: 5, Y: 5}) - a.QueryPosition(vector.Vector{X: -5, Y: -5}) + // Get a tile to spawn a 4x4 grid of chunks + a.QueryPosition(vector.Vector{X: 3, Y: 3}) + assert.Equal(t, 2*2, len(a.Chunks)) + a.QueryPosition(vector.Vector{X: -3, Y: -3}) + assert.Equal(t, 4*4, len(a.Chunks)) + // Chunks should look like: // 12| 13|| 14| 15 // ---------------- @@ -67,14 +73,96 @@ func TestAtlas_toChunk(t *testing.T) { // 4 | 5 || 6 | 7 // ---------------- // 0 | 1 || 2 | 3 - chunkID = a.worldSpaceToChunk(vector.Vector{X: 1, Y: 3}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 1, Y: 3}) assert.Equal(t, 14, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: 1, Y: -3}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 1, Y: -3}) assert.Equal(t, 2, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: -1, Y: -1}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: -1, Y: -1}) assert.Equal(t, 5, chunkID) - chunkID = a.worldSpaceToChunk(vector.Vector{X: -2, Y: 2}) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: -2, Y: 2}) assert.Equal(t, 13, chunkID) + + a = NewAtlas(3) + assert.NotNil(t, a) + // Get a tile to spawn a 4x4 grid of chunks + a.QueryPosition(vector.Vector{X: 3, Y: 3}) + assert.Equal(t, 2*2, len(a.Chunks)) + + // Chunks should look like: + // || 2| 3 + // ------- + // || 0| 1 + // ======= + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 1, Y: 1}) + assert.Equal(t, 0, chunkID) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 3, Y: 1}) + assert.Equal(t, 1, chunkID) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 1, Y: 4}) + assert.Equal(t, 2, chunkID) + chunkID = a.worldSpaceToChunkIndex(vector.Vector{X: 5, Y: 5}) + assert.Equal(t, 3, chunkID) +} + +func TestAtlas_toWorld(t *testing.T) { + a := NewAtlas(1) + assert.NotNil(t, a) + + // Get a tile to spawn some chunks + a.QueryPosition(vector.Vector{X: -1, Y: -1}) + assert.Equal(t, 2*2, len(a.Chunks)) + + // Chunks should look like: + // 2 | 3 + // ----- + // 0 | 1 + assert.Equal(t, vector.Vector{X: -1, Y: -1}, a.chunkOriginInWorldSpace(0)) + assert.Equal(t, vector.Vector{X: 0, Y: -1}, a.chunkOriginInWorldSpace(1)) + + a = NewAtlas(2) + assert.NotNil(t, a) + // Get a tile to spawn the chunks + a.QueryPosition(vector.Vector{X: -2, Y: -2}) + assert.Equal(t, 2*2, len(a.Chunks)) + a.QueryPosition(vector.Vector{X: 1, Y: 1}) + assert.Equal(t, 2*2, len(a.Chunks)) + // Chunks should look like: + // 2 | 3 + // ----- + // 0 | 1 + assert.Equal(t, vector.Vector{X: -2, Y: -2}, a.chunkOriginInWorldSpace(0)) + assert.Equal(t, vector.Vector{X: -2, Y: 0}, a.chunkOriginInWorldSpace(2)) + + a = NewAtlas(2) + assert.NotNil(t, a) + // Get a tile to spawn a 4x4 grid of chunks + a.QueryPosition(vector.Vector{X: 3, Y: 3}) + assert.Equal(t, 2*2, len(a.Chunks)) + a.QueryPosition(vector.Vector{X: -3, Y: -3}) + assert.Equal(t, 4*4, len(a.Chunks)) + + // Chunks should look like: + // 12| 13|| 14| 15 + // ---------------- + // 8 | 9 || 10| 11 + // ================ + // 4 | 5 || 6 | 7 + // ---------------- + // 0 | 1 || 2 | 3 + assert.Equal(t, vector.Vector{X: -4, Y: -4}, a.chunkOriginInWorldSpace(0)) + assert.Equal(t, vector.Vector{X: 2, Y: -2}, a.chunkOriginInWorldSpace(7)) + + a = NewAtlas(3) + assert.NotNil(t, a) + // Get a tile to spawn a 4x4 grid of chunks + a.QueryPosition(vector.Vector{X: 3, Y: 3}) + assert.Equal(t, 2*2, len(a.Chunks)) + + // Chunks should look like: + // || 2| 3 + // ------- + // || 0| 1 + // ======= + assert.Equal(t, vector.Vector{X: 0, Y: 0}, a.chunkOriginInWorldSpace(0)) } func TestAtlas_GetSetTile(t *testing.T) { @@ -137,3 +225,26 @@ func TestAtlas_Grown(t *testing.T) { tile, _ = a.QueryPosition(vector.Vector{X: 1, Y: -2}) assert.Equal(t, byte(3), tile) } + +func TestAtlas_GetSetCorrect(t *testing.T) { + // Big stress test to ensure we do actually properly expand for all reasonable values + for i := 1; i <= 4; i++ { + + for x := -i * 2; x < i*2; x++ { + for y := -i * 2; y < i*2; y++ { + a := NewAtlas(i) + assert.NotNil(t, a) + assert.Equal(t, 1, len(a.Chunks)) + + pos := vector.Vector{X: x, Y: y} + a.SetTile(pos, TileRock) + a.SetObject(pos, objects.Object{Type: objects.LargeRock}) + tile, obj := a.QueryPosition(pos) + + assert.Equal(t, TileRock, Tile(tile)) + assert.Equal(t, objects.Object{Type: objects.LargeRock}, obj) + + } + } + } +} diff --git a/pkg/maths/maths.go b/pkg/maths/maths.go index 76c00a5..cd40516 100644 --- a/pkg/maths/maths.go +++ b/pkg/maths/maths.go @@ -39,3 +39,19 @@ func Min(x, y int) int { } return x } + +// RoundUp rounds a value up to the nearest multiple +func RoundUp(toRound int, multiple int) int { + remainder := Pmod(toRound, multiple) + if remainder == 0 { + return toRound + } + + return (multiple - remainder) + toRound +} + +// RoundDown rounds a value down to the nearest multiple +func RoundDown(toRound int, multiple int) int { + remainder := Pmod(toRound, multiple) + return toRound - remainder +} diff --git a/pkg/maths/maths_test.go b/pkg/maths/maths_test.go index cf2c180..730da77 100644 --- a/pkg/maths/maths_test.go +++ b/pkg/maths/maths_test.go @@ -29,5 +29,19 @@ func TestMin(t *testing.T) { assert.Equal(t, 100, Min(100, 500)) assert.Equal(t, -4, Min(-4, 1)) assert.Equal(t, -4, Min(-4, -2)) +} + +func TestRoundUp(t *testing.T) { + assert.Equal(t, 10, RoundUp(10, 5)) + assert.Equal(t, 12, RoundUp(10, 4)) + assert.Equal(t, -8, RoundUp(-8, 4)) + assert.Equal(t, -4, RoundUp(-7, 4)) +} + +func TestRoundDown(t *testing.T) { + assert.Equal(t, 10, RoundDown(10, 5)) + assert.Equal(t, 8, RoundDown(10, 4)) + assert.Equal(t, -8, RoundDown(-8, 4)) + assert.Equal(t, -8, RoundDown(-7, 4)) } diff --git a/pkg/vector/vector.go b/pkg/vector/vector.go index b661d17..a8955be 100644 --- a/pkg/vector/vector.go +++ b/pkg/vector/vector.go @@ -50,6 +50,25 @@ func (v Vector) Divided(val int) Vector { return Vector{v.X / val, v.Y / val} } +// DividedFloor returns the vector divided but floors the value regardless +func (v Vector) DividedFloor(val int) Vector { + x := float64(v.X) / float64(val) + + if x < 0 { + x = math.Floor(x) + } else { + x = math.Floor(x) + } + y := float64(v.Y) / float64(val) + if y < 0 { + y = math.Floor(y) + } else { + y = math.Floor(y) + } + + return Vector{X: int(x), Y: int(y)} +} + // Abs returns an absolute version of the vector func (v Vector) Abs() Vector { return Vector{maths.Abs(v.X), maths.Abs(v.Y)}