diff --git a/d2core/d2map/engine.go b/d2core/d2map/engine.go index f0f0b6d9..a33a1f6d 100644 --- a/d2core/d2map/engine.go +++ b/d2core/d2map/engine.go @@ -25,7 +25,7 @@ type MapEngine struct { debugVisLevel int regions []*MapRegion - entities []MapEntity + entities MapEntitiesSearcher viewport *Viewport camera Camera } @@ -34,6 +34,7 @@ func CreateMapEngine(gameState *d2gamestate.GameState) *MapEngine { engine := &MapEngine{ gameState: gameState, viewport: NewViewport(0, 0, 800, 600), + entities: NewRangeSearcher(), } d2term.BindAction("mapdebugvis", "set map debug visualization level", func(level int) { @@ -88,7 +89,7 @@ func (m *MapEngine) WorldToOrtho(x, y float64) (float64, float64) { func (m *MapEngine) GenerateMap(regionType d2enum.RegionIdType, levelPreset int, fileIndex int) { region, entities := loadRegion(m.gameState.Seed, 0, 0, regionType, levelPreset, fileIndex) m.regions = append(m.regions, region) - m.entities = append(m.entities, entities...) + m.entities.Add(entities...) } func (m *MapEngine) GenerateAct1Overworld() { @@ -96,16 +97,16 @@ func (m *MapEngine) GenerateAct1Overworld() { region, entities := loadRegion(m.gameState.Seed, 0, 0, d2enum.RegionAct1Town, 1, -1) m.regions = append(m.regions, region) - m.entities = append(m.entities, entities...) + m.entities.Add(entities...) if strings.Contains(region.regionPath, "E1") { region, entities := loadRegion(m.gameState.Seed, region.tileRect.Width-1, 0, d2enum.RegionAct1Town, 2, -1) m.AppendRegion(region) - m.entities = append(m.entities, entities...) + m.entities.Add(entities...) } else if strings.Contains(region.regionPath, "S1") { region, entities := loadRegion(m.gameState.Seed, 0, region.tileRect.Height-1, d2enum.RegionAct1Town, 3, -1) m.AppendRegion(region) - m.entities = append(m.entities, entities...) + m.entities.Add(entities...) } } @@ -125,7 +126,7 @@ func (m *MapEngine) GetRegionAtTile(x, y int) *MapRegion { } func (m *MapEngine) AddEntity(entity MapEntity) { - m.entities = append(m.entities, entity) + m.entities.Add(entity) } func (m *MapEngine) RemoveEntity(entity MapEntity) { @@ -133,15 +134,7 @@ func (m *MapEngine) RemoveEntity(entity MapEntity) { return } - // In-place filter to remove the given entity. - n := 0 - for _, check := range m.entities { - if check != entity { - m.entities[n] = check - n++ - } - } - m.entities = m.entities[:n] + m.entities.Remove(entity) } func (m *MapEngine) Advance(tickTime float64) { @@ -151,9 +144,11 @@ func (m *MapEngine) Advance(tickTime float64) { } } - for _, entity := range m.entities { + for _, entity := range m.entities.All() { entity.Advance(tickTime) } + + m.entities.Update() } func (m *MapEngine) Render(target d2render.Surface) { diff --git a/d2core/d2map/range_searcher.go b/d2core/d2map/range_searcher.go new file mode 100644 index 00000000..d0f71d6c --- /dev/null +++ b/d2core/d2map/range_searcher.go @@ -0,0 +1,148 @@ +package d2map + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2common" + "math" + "sort" +) + +type MapEntitiesSearcher interface { + // Returns all map entities. + All() []MapEntity + // Add adds an entity to the index and re-sorts. + Add(entities ...MapEntity) + // Remove finds and removes the entity from the index. + Remove(entity MapEntity) + // SearchByRect get entities in a rectangle, results will be sorted top left to bottom right. + // Elements with equal Y will be sorted by X + SearchByRect(rect d2common.Rectangle) []MapEntity + // SearchByRadius get entities in a circle, results will be sorted top left to bottom right. + // Elements with equal Y will be sorted by X + SearchByRadius(originX, originY, radius float64) []MapEntity + // Update re-sorts the index, must be ran after each update. + Update() +} + +// rangeSearcher a basic index of entity locations using a slice ordered by Y then X coordinates. +// Eventually this should be probably replaced with a proper spatial index. +type rangeSearcher struct { + entities []MapEntity +} + +func NewRangeSearcher() MapEntitiesSearcher { + return &rangeSearcher{ + entities: make([]MapEntity, 0, 64), + } +} + +func (r *rangeSearcher) All() []MapEntity { + return r.entities +} + +func (r *rangeSearcher) Add(entities ...MapEntity) { + r.entities = append(r.entities, entities...) + + r.Update() +} + +func (r *rangeSearcher) Remove(entity MapEntity) { + if entity == nil { + return + } + + // In-place filter to remove the given entity. + n := 0 + for _, check := range r.entities { + if check != entity { + r.entities[n] = check + n++ + } + } + r.entities = r.entities[:n] +} + +func (r *rangeSearcher) SearchByRect(rect d2common.Rectangle) []MapEntity { + left, top, right, bottom := float64(rect.Left), float64(rect.Top), float64(rect.Right()), float64(rect.Bottom()) + topIndex := sort.Search(len(r.entities), func(i int) bool { + x, y := r.entities[i].GetPosition() + if y == top { + return x >= left + } + return y >= top + }) + + matches := make([]MapEntity, 0, 16) + + for i := topIndex; i < len(r.entities); i++ { + x, y := r.entities[i].GetPosition() + if y > bottom { + break + } + + if x >= left && x <= right { + matches = append(matches, r.entities[i]) + } + + } + + return matches +} + +func (r *rangeSearcher) SearchByRadius(originX, originY, radius float64) []MapEntity { + left, right := originX-radius, originX+radius + top, bottom := originY-radius, originY+radius + inRect := r.SearchByRect(d2common.Rectangle{ + Left: int(left), + Top: int(top), + Width: int(right - left), + Height: int(bottom - top), + }) + + // In-place filter to remove entities outside the radius. + n := 0 + for _, check := range inRect { + x, y := check.GetPosition() + if distance(originX, originY, x, y) <= radius { + inRect[n] = check + n++ + } + } + return inRect[:n] +} + +func distance(x1, y1, x2, y2 float64) float64 { + return math.Abs(math.Sqrt(math.Pow(x2-x1, 2) + math.Pow(y2-y1, 2))) +} + +// Re-sorts the index after entities have moved. +// Uses bubble sort to target O(n) sort time, in most cases no entities will be swapped. +func (r *rangeSearcher) Update() { + bubbleSort(r.entities, func(i, j int) bool { + ix, iy := r.entities[i].GetPosition() + jx, jy := r.entities[j].GetPosition() + if iy == jy { + return ix < jx + } + return iy < jy + }) +} + +func bubbleSort(items []MapEntity, less func(i, j int) bool) { + var ( + n = len(items) + sorted = false + ) + for !sorted { + swapped := false + for i := 0; i < n-1; i++ { + if less(i+1, i) { + items[i+1], items[i] = items[i], items[i+1] + swapped = true + } + } + if !swapped { + sorted = true + } + n = n - 1 + } +} diff --git a/d2core/d2map/range_searcher_test.go b/d2core/d2map/range_searcher_test.go new file mode 100644 index 00000000..46a6d746 --- /dev/null +++ b/d2core/d2map/range_searcher_test.go @@ -0,0 +1,124 @@ +package d2map + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2common" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2render" + "github.com/stretchr/testify/assert" + "testing" +) + +type mockEntity struct { + x float64 + y float64 +} + +func (m *mockEntity) Render(target d2render.Surface) { + panic("implement me") +} + +func (m *mockEntity) Advance(tickTime float64) { + panic("implement me") +} + +func (m *mockEntity) GetPosition() (float64, float64) { + return m.x, m.y +} + +func newMockEntity(x, y float64) MapEntity { + return &mockEntity{ + x: x, + y: y, + } +} + +func TestRangeSearcher_Add(t *testing.T) { + searcher := &rangeSearcher{ + entities: make([]MapEntity, 0, 64), + } + + searcher.Add( + newMockEntity(0, 9), + newMockEntity(8, 1), + newMockEntity(1, 8), + newMockEntity(3, 6), + newMockEntity(5, 4), + newMockEntity(6, 3), + newMockEntity(9, 0), + newMockEntity(4, 5), + newMockEntity(2, 7), + newMockEntity(7, 2), + ) + + for i := 0; i <= 9; i++ { + _, pos := searcher.entities[i].GetPosition() + assert.Equal(t, float64(i), pos) + } + +} + +func TestRangeSearcher_SearchByRect(t *testing.T) { + searcher := &rangeSearcher{ + entities: make([]MapEntity, 0, 64), + } + + searcher.Add( + newMockEntity(0, 9), + newMockEntity(8, 1), + newMockEntity(1, 8), + newMockEntity(3, 6), + newMockEntity(5, 4), + newMockEntity(6, 3), + newMockEntity(9, 0), + newMockEntity(4, 5), + newMockEntity(2, 7), + newMockEntity(7, 2), + ) + + matches := searcher.SearchByRect(d2common.Rectangle{ + Left: 3, + Top: 0, + Width: 4, + Height: 9, + }) + + valsX := make([]float64, 0) + for _, match := range matches { + x, _ := match.GetPosition() + valsX = append(valsX, x) + } + + assert.ElementsMatch(t, []float64{3, 4, 5, 6, 7}, valsX) + + matches = searcher.SearchByRect(d2common.Rectangle{ + Left: 0, + Top: 1, + Width: 9, + Height: 4, + }) + + valsY := make([]float64, 0) + for _, match := range matches { + _, y := match.GetPosition() + valsY = append(valsY, y) + } + + assert.ElementsMatch(t, []float64{1, 2, 3, 4, 5}, valsY) + + matches = searcher.SearchByRect(d2common.Rectangle{ + Left: 3, + Top: 3, + Width: 2, + Height: 2, + }) + + valsY = make([]float64, 0) + valsX = make([]float64, 0) + for _, match := range matches { + x, y := match.GetPosition() + valsX = append(valsX, x) + valsY = append(valsY, y) + } + + assert.ElementsMatch(t, []float64{4, 5}, valsY) + assert.ElementsMatch(t, []float64{4, 5}, valsX) +} diff --git a/d2core/d2map/region.go b/d2core/d2map/region.go index ce786f47..c04e3a19 100644 --- a/d2core/d2map/region.go +++ b/d2core/d2map/region.go @@ -374,7 +374,10 @@ func (mr *MapRegion) renderPass1(viewport *Viewport, target d2render.Surface) { } } -func (mr *MapRegion) renderPass2(entities []MapEntity, viewport *Viewport, target d2render.Surface) { +func (mr *MapRegion) renderPass2(entities MapEntitiesSearcher, viewport *Viewport, target d2render.Surface) { + tileEntities := entities.SearchByRect(mr.tileRect) + nextIndex := 0 + for tileY := range mr.ds1.Tiles { for tileX, tile := range mr.ds1.Tiles[tileY] { worldX, worldY := mr.getTileWorldPosition(tileX, tileY) @@ -382,12 +385,17 @@ func (mr *MapRegion) renderPass2(entities []MapEntity, viewport *Viewport, targe viewport.PushTranslationWorld(worldX, worldY) mr.renderTilePass2(tile, viewport, target) - for _, entity := range entities { - entWorldX, entWorldY := entity.GetPosition() - if entWorldX == worldX && entWorldY == worldY { + for nextIndex < len(tileEntities) { + nextX, nextY := tileEntities[nextIndex].GetPosition() + if nextX == worldX && nextY == worldY { target.PushTranslation(viewport.GetTranslationScreen()) - entity.Render(target) + tileEntities[nextIndex].Render(target) target.Pop() + nextIndex++ + } else if (nextY == worldY && nextX < worldX) || nextY < worldY { + nextIndex++ + } else { + break } }