From b2ca89d8193d98aa21e9637257c3e7a7216b9bd8 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 7 May 2026 10:31:42 +0200 Subject: Add viewport/camera system with pan, zoom, and world-space cursor conversion - New camera.go: Camera struct with arrow-key pan, scroll-wheel zoom toward cursor, middle-mouse drag, exponential velocity decay, and world-bounds clamping - Layout() now returns screen dimensions (960x640) instead of world size so the camera GeoM transform drives all rendering - All world entities, halos, text, path lines and spawn target converted via camera transform in draw.go; banners remain in screen space - selection.go: separate startScreen field so the drag-select box draws in screen space while selection logic stays in world space - Banner heights and font sizes scaled down to fit the 640px logical screen - Three new integration tests: ScreenToWorld round-trip, bounds clamping, zoom-toward-cursor stability Co-Authored-By: Claude Sonnet 4.6 --- integration_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) (limited to 'integration_test.go') diff --git a/integration_test.go b/integration_test.go index b8f9ba5..4525b18 100644 --- a/integration_test.go +++ b/integration_test.go @@ -9,6 +9,68 @@ import ( "github.com/stretchr/testify/assert" ) +func TestCameraScreenToWorldRoundTrip(t *testing.T) { + zooms := []float64{0.25, 0.5, 1.0, 2.0, 3.0} + points := []physics.Point{ + {X: 1000, Y: 1000}, + {X: 0, Y: 0}, + {X: 3200, Y: 2400}, + {X: 1600, Y: 1200}, + } + for _, zoom := range zooms { + cam := Camera{X: 1600, Y: 1200, Zoom: zoom} + for _, p := range points { + sx, sy := cam.WorldToScreen(p) + got := cam.ScreenToWorld(int(sx), int(sy)) + assert.Equal(t, p, got, "zoom=%.2f point=%v", zoom, p) + } + } +} + +func TestCameraClampsToBounds(t *testing.T) { + cases := []struct{ x, y float64 }{ + {-99999, -99999}, + {99999, 99999}, + {-99999, 99999}, + {99999, -99999}, + } + for _, c := range cases { + cam := Camera{X: c.x, Y: c.y, Zoom: 1.0} + cam.clampToBounds() + halfW := (ScreenWidth / 2.0) / cam.Zoom + halfH := (ScreenHeight / 2.0) / cam.Zoom + assert.GreaterOrEqual(t, cam.X, halfW, "X=%v should be >= halfW", c.x) + assert.LessOrEqual(t, cam.X, float64(WorldWidth)-halfW, "X=%v should be <= maxX", c.x) + assert.GreaterOrEqual(t, cam.Y, halfH, "Y=%v should be >= halfH", c.y) + assert.LessOrEqual(t, cam.Y, float64(WorldHeight)-halfH, "Y=%v should be <= maxY", c.y) + } +} + +func TestCameraZoomTowardCursor(t *testing.T) { + zooms := []struct{ before, after float64 }{ + {1.0, 2.0}, + {1.0, 0.5}, + {2.0, 3.0}, + } + cursorSX, cursorSY := 400, 300 + for _, z := range zooms { + cam := Camera{X: 1600, Y: 1200, Zoom: z.before} + worldBefore := cam.ScreenToWorld(cursorSX, cursorSY) + + // replicate the zoom-toward-cursor logic from Camera.Update + wx0 := float64(cursorSX-ScreenWidth/2)/cam.Zoom + cam.X + wy0 := float64(cursorSY-ScreenHeight/2)/cam.Zoom + cam.Y + cam.Zoom = z.after + wx1 := float64(cursorSX-ScreenWidth/2)/cam.Zoom + cam.X + wy1 := float64(cursorSY-ScreenHeight/2)/cam.Zoom + cam.Y + cam.X -= wx1 - wx0 + cam.Y -= wy1 - wy0 + + worldAfter := cam.ScreenToWorld(cursorSX, cursorSY) + assert.Equal(t, worldBefore, worldAfter, "zoom %.1f→%.1f: world point under cursor should be stable", z.before, z.after) + } +} + func TestGatherLoop(t *testing.T) { // Initialize Game game := &Game{} -- cgit v1.3