package world import ( "image" "image/color" "testing" "github.com/stretchr/testify/require" ) type bgOffsetScaleTheme struct { img image.Image anchor BackgroundAnchorMode } func (t bgOffsetScaleTheme) ID() string { return "bgoffset" } func (t bgOffsetScaleTheme) Name() string { return "bgoffset" } func (t bgOffsetScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } func (t bgOffsetScaleTheme) BackgroundImage() image.Image { return t.img } func (t bgOffsetScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } func (t bgOffsetScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } func (t bgOffsetScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor } func (t bgOffsetScaleTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} } func (t bgOffsetScaleTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgOffsetScaleTheme) CircleStyle() Style { return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgOffsetScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgOffsetScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgOffsetScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } func TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan(t *testing.T) { t.Parallel() w := NewWorld(20, 20) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) // tile 4x4 w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorWorld}) params := RenderParams{ ViewportWidthPx: 8, ViewportHeightPx: 8, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } // First render. d1 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d1, params)) minX1, minY1 := minDrawImageXY(t, d1) require.Equal(t, -1, minX1) require.Equal(t, -1, minY1) // Pan camera by +1 world unit along both axes (zoom=1 => 1px). params2 := params params2.CameraXWorldFp += 1 * SCALE params2.CameraYWorldFp += 1 * SCALE // Force full redraw to make this test independent of incremental pipeline. w.ForceFullRedrawNext() d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, params2)) minX2, minY2 := minDrawImageXY(t, d2) // With world anchoring, moving camera +1 shifts the tiling origin by -1 (mod tile size). require.Equal(t, -2, minX2) require.Equal(t, -2, minY2) } func TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan(t *testing.T) { t.Parallel() w := NewWorld(20, 20) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorViewport}) params := RenderParams{ ViewportWidthPx: 8, ViewportHeightPx: 8, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d1 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d1, params)) minX1, minY1 := minDrawImageXY(t, d1) params2 := params params2.CameraXWorldFp += 1 * SCALE params2.CameraYWorldFp += 1 * SCALE w.ForceFullRedrawNext() d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, params2)) minX2, minY2 := minDrawImageXY(t, d2) // With viewport anchoring, tiling origin is fixed (no camera dependency). require.Equal(t, minX1, minX2) require.Equal(t, minY1, minY2) } func minDrawImageXY(t *testing.T, d *fakePrimitiveDrawer) (int, int) { t.Helper() cmds := d.CommandsByName("DrawImage") require.NotEmpty(t, cmds, "expected DrawImage calls from background tiling") minX := int(cmds[0].Args[0]) minY := int(cmds[0].Args[1]) for _, c := range cmds[1:] { x := int(c.Args[0]) y := int(c.Args[1]) if x < minX { minX = x } if y < minY { minY = y } } return minX, minY }