diff --git a/play/isometricprojection.go b/play/isometricprojection.go new file mode 100644 index 0000000..4a18b0c --- /dev/null +++ b/play/isometricprojection.go @@ -0,0 +1,158 @@ +package play + +import ( + "opslag.de/schobers/geom" +) + +// IsometricProjection represents an 2D area (view) that contains isometric tiles. +type IsometricProjection struct { + center geom.PointF32 // tile coordinate + zoom float32 // factor a tile is blown up (negative is smaller, possitive is larger) + zoomInverse float32 // 1/zoom; calculated + + tileSize geom.PointF32 // size of a single tile (maximum width & height difference of its corners) + viewBounds geom.RectangleF32 // bounds of the view (screen coordinates) + viewCenter geom.PointF32 // center of view; calculated + + tileSizeTransformed geom.PointF32 // calculated + tileToViewTransformation geom.PointF32 // calculated + viewToTileTransformation geom.PointF32 // calculated +} + +// NewIsometricProjection creates a new isometric projection. By default the tile with the coordinate (0, 0) will be centered in the viewBounds. The tile size is represented with maximum width & height difference of its corners. +func NewIsometricProjection(tileSize geom.PointF32, viewBounds geom.RectangleF32) *IsometricProjection { + p := &IsometricProjection{zoom: 1, tileSize: tileSize, viewBounds: viewBounds} + p.update() + return p +} + +func (p *IsometricProjection) update() { + if p.zoom == 0 { + p.zoom = 1 + } + p.zoomInverse = 1 / p.zoom + + p.viewCenter = p.viewBounds.Center() + + p.tileSizeTransformed = p.tileSize.Mul(p.zoom) + p.tileToViewTransformation = p.tileSize.Mul(.5 * p.zoom) + p.viewToTileTransformation = geom.PtF32(1/p.tileSizeTransformed.X, 1/p.tileSizeTransformed.Y) +} + +// Center gives back the coordinate of the center tile +func (p *IsometricProjection) Center() geom.PointF32 { return p.center } + +// Enumerate enumerates all tiles in the set view bounds and calls action for every tile. +func (p *IsometricProjection) Enumerate(action func(tile geom.PointF32, view geom.PointF32)) { + p.EnumerateInt(func(tile geom.Point, view geom.PointF32) { + action(tile.ToF32(), view) + }) +} + +// EnumerateInt enumerates all tiles in the set view bounds and calls action for every tile. +func (p *IsometricProjection) EnumerateInt(action func(tile geom.Point, view geom.PointF32)) { + visible := p.viewBounds + visible.Max.Y += p.tileSize.Y * p.zoom + topLeft := p.ViewToTile(geom.PtF32(visible.Min.X, visible.Min.Y)) + topRight := p.ViewToTile(geom.PtF32(visible.Max.X, visible.Min.Y)) + bottomLeft := p.ViewToTile(geom.PtF32(visible.Min.X, visible.Max.Y)) + bottomRight := p.ViewToTile(geom.PtF32(visible.Max.X, visible.Max.Y)) + minY, maxY := int(geom.Floor32(topRight.Y)), int(geom.Ceil32(bottomLeft.Y)) + minX, maxX := int(geom.Floor32(topLeft.X)), int(geom.Ceil32(bottomRight.X)) + tileOffset := p.tileSizeTransformed.Mul(.5) + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + tile := geom.Pt(x, y) + view := p.TileToView(tile.ToF32()) + if view.X+tileOffset.X < visible.Min.X || view.Y+tileOffset.Y < visible.Min.Y { + continue + } + if view.X-tileOffset.X > visible.Max.X || view.Y-tileOffset.Y > visible.Max.Y { + break + } + action(tile, view) + } + } +} + +// MoveCenterTo moves the center of the projection to the given tile. +func (p *IsometricProjection) MoveCenterTo(tile geom.PointF32) { + p.center = tile + p.update() +} + +// Pan translates the center of the projection with the given delta in view coordinates. +func (p *IsometricProjection) Pan(delta geom.PointF32) { + p.MoveCenterTo(p.center.Add(delta.Mul(p.zoomInverse))) +} + +// SetTileSize sets the size of a single tile (maximum width & height difference of its corners). +func (p *IsometricProjection) SetTileSize(size geom.PointF32) { + p.tileSize = size + p.update() +} + +// SetViewBounds sets the bounds of the view coordinates. Used to calculate the center with & for calculating the visible tiles. +func (p *IsometricProjection) SetViewBounds(bounds geom.RectangleF32) { + p.viewBounds = bounds + p.update() +} + +// SetZoom changes the zoom to and keeps the around (tile) coordinate on the same position. +func (p *IsometricProjection) SetZoom(around geom.PointF32, zoom float32) { + if p.zoom == zoom { + return + } + p.center = around.Sub(around.Sub(p.center).Mul(p.zoom / zoom)) + p.zoom = zoom + p.update() +} + +// TileInt gives the integer tile coordinate. +func (p *IsometricProjection) TileInt(tile geom.PointF32) geom.Point { + return geom.Pt(int(geom.Round32(tile.X)), int(geom.Round32(tile.Y))) +} + +// TileToView transforms the tile coordinate to the corresponding view coordinate. +func (p *IsometricProjection) TileToView(tile geom.PointF32) geom.PointF32 { + translated := tile.Sub(p.center) + return p.viewCenter.Add2D((translated.X-translated.Y)*p.tileToViewTransformation.X, (translated.X+translated.Y)*p.tileToViewTransformation.Y) +} + +// ViewCenter returns the center of the view (calculated from the set view bounds). +func (p *IsometricProjection) ViewCenter() geom.PointF32 { return p.viewCenter } + +// ViewToTile transforms the view coordinate to the corresponding tile coordinate. +func (p *IsometricProjection) ViewToTile(view geom.PointF32) geom.PointF32 { + return p.ViewToTileRelative(view.Sub(p.viewCenter)).Add(p.center) +} + +// ViewToTileInt transforms the view coordinate to the corresponding integer tile coordinate. +func (p *IsometricProjection) ViewToTileInt(view geom.PointF32) geom.Point { + tile := p.ViewToTile(view) + return p.TileInt(tile) +} + +// ViewToTileRelative transforms the relative (to 0,0) view coordinate to the corresponding tile coordinate +func (p *IsometricProjection) ViewToTileRelative(view geom.PointF32) geom.PointF32 { + return geom.PtF32(view.X*p.viewToTileTransformation.X+view.Y*p.viewToTileTransformation.Y, -view.X*p.viewToTileTransformation.X+view.Y*p.viewToTileTransformation.Y) +} + +// Zoom returns the current zoom. +func (p *IsometricProjection) Zoom() float32 { return p.zoom } + +// ZoomIn zooms in around the given tile coordinate. +func (p *IsometricProjection) ZoomIn(around geom.PointF32) { + if p.zoom >= 2 { + return + } + p.SetZoom(around, 2*p.zoom) +} + +// ZoomOut zooms in around the given tile coordinate. +func (p *IsometricProjection) ZoomOut(around geom.PointF32) { + if p.zoom <= .25 { + return + } + p.SetZoom(around, .5*p.zoom) +} diff --git a/play/isometricprojection_test.go b/play/isometricprojection_test.go new file mode 100644 index 0000000..071de8d --- /dev/null +++ b/play/isometricprojection_test.go @@ -0,0 +1,36 @@ +package play + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "opslag.de/schobers/geom" +) + +func createIsometricProjection() *IsometricProjection { + return NewIsometricProjection(geom.PtF32(23, 11), geom.RectRelF32(0, 0, 160, 160)) +} + +func TestViewToTile(t *testing.T) { + p := createIsometricProjection() + assert.Equal(t, geom.PtF32(0, 0), p.ViewToTile(geom.PtF32(80, 80))) + assert.Equal(t, geom.PtF32(-1, 1), p.ViewToTile(geom.PtF32(57, 80))) + assert.Equal(t, geom.PtF32(2, 2), p.ViewToTile(geom.PtF32(80, 102))) + assert.Equal(t, geom.PtF32(-1, -3), p.ViewToTile(geom.PtF32(103, 58))) +} + +func TestViewToTileInt(t *testing.T) { + p := createIsometricProjection() + assert.Equal(t, geom.Pt(0, 0), p.ViewToTileInt(geom.PtF32(80, 80))) + assert.Equal(t, geom.Pt(0, 0), p.ViewToTileInt(geom.PtF32(69, 80))) + assert.Equal(t, geom.Pt(-1, 1), p.ViewToTileInt(geom.PtF32(68, 80))) +} + +func TestTileToView(t *testing.T) { + p := createIsometricProjection() + assert.Equal(t, geom.PtF32(80, 80), p.TileToView(geom.PtF32(0, 0))) + assert.Equal(t, geom.PtF32(57, 80), p.TileToView(geom.PtF32(-1, 1))) + assert.Equal(t, geom.PtF32(80, 102), p.TileToView(geom.PtF32(2, 2))) + assert.Equal(t, geom.PtF32(103, 58), p.TileToView(geom.PtF32(-1, -3))) +}