aboutsummaryrefslogtreecommitdiff
path: root/camera.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-05-07 10:31:42 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-05-07 10:31:42 +0200
commitb2ca89d8193d98aa21e9637257c3e7a7216b9bd8 (patch)
treee87be16d5a92ad88070f3c96976174cab683f571 /camera.go
parent2e8da8cb6948664a77fa73cb0c66e0cc27419dc5 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'camera.go')
-rw-r--r--camera.go174
1 files changed, 174 insertions, 0 deletions
diff --git a/camera.go b/camera.go
new file mode 100644
index 0000000..9118117
--- /dev/null
+++ b/camera.go
@@ -0,0 +1,174 @@
+package main
+
+import (
+ "math"
+
+ "age_of_empires/physics"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/inpututil"
+)
+
+const (
+ ScreenWidth = 960
+ ScreenHeight = 640
+ WorldWidth = 3200
+ WorldHeight = 2400
+
+ cameraZoomMin = 0.25
+ cameraZoomMax = 3.0
+ cameraZoomStep = 0.10
+ cameraScrollSpeed = 400.0
+ cameraDrag = 8.0
+)
+
+type Camera struct {
+ X, Y float64
+ Zoom float64
+
+ vx, vy float64
+
+ dragActive bool
+ dragStartScreenX, dragStartScreenY int
+ dragStartCamX, dragStartCamY float64
+}
+
+func (c *Camera) WorldToScreen(p physics.Point) (float64, float64) {
+ sx := (float64(p.X)-c.X)*c.Zoom + ScreenWidth/2
+ sy := (float64(p.Y)-c.Y)*c.Zoom + ScreenHeight/2
+ return sx, sy
+}
+
+func (c *Camera) ScreenToWorld(sx, sy int) physics.Point {
+ zoom := c.Zoom
+ if zoom == 0 {
+ zoom = 1
+ }
+ wx := float64(sx-ScreenWidth/2)/zoom + c.X
+ wy := float64(sy-ScreenHeight/2)/zoom + c.Y
+ return physics.Point{X: int(math.Round(wx)), Y: int(math.Round(wy))}
+}
+
+func (c *Camera) GeoM() ebiten.GeoM {
+ var m ebiten.GeoM
+ m.Translate(-c.X, -c.Y)
+ m.Scale(c.Zoom, c.Zoom)
+ m.Translate(ScreenWidth/2, ScreenHeight/2)
+ return m
+}
+
+func (c *Camera) clampToBounds() {
+ halfW := (ScreenWidth / 2.0) / c.Zoom
+ halfH := (ScreenHeight / 2.0) / c.Zoom
+
+ minX, maxX := halfW, float64(WorldWidth)-halfW
+ if minX > maxX {
+ minX = WorldWidth / 2
+ maxX = WorldWidth / 2
+ }
+ minY, maxY := halfH, float64(WorldHeight)-halfH
+ if minY > maxY {
+ minY = WorldHeight / 2
+ maxY = WorldHeight / 2
+ }
+
+ if c.X < minX {
+ c.X = minX
+ if c.vx < 0 {
+ c.vx = 0
+ }
+ }
+ if c.X > maxX {
+ c.X = maxX
+ if c.vx > 0 {
+ c.vx = 0
+ }
+ }
+ if c.Y < minY {
+ c.Y = minY
+ if c.vy < 0 {
+ c.vy = 0
+ }
+ }
+ if c.Y > maxY {
+ c.Y = maxY
+ if c.vy > 0 {
+ c.vy = 0
+ }
+ }
+}
+
+func (c *Camera) Update(dt float64) {
+ zoom := c.Zoom
+ if zoom == 0 {
+ zoom = 1
+ }
+
+ // Scroll wheel zoom toward cursor
+ _, wy := ebiten.Wheel()
+ if wy != 0 {
+ mx, my := ebiten.CursorPosition()
+ // cursor world pos before zoom
+ wx0 := float64(mx-ScreenWidth/2)/zoom + c.X
+ wy0 := float64(my-ScreenHeight/2)/zoom + c.Y
+
+ newZoom := math.Max(cameraZoomMin, math.Min(cameraZoomMax, zoom+wy*cameraZoomStep))
+ c.Zoom = newZoom
+ zoom = newZoom
+
+ // translate so cursor stays fixed
+ wx1 := float64(mx-ScreenWidth/2)/zoom + c.X
+ wy1 := float64(my-ScreenHeight/2)/zoom + c.Y
+ c.X -= wx1 - wx0
+ c.Y -= wy1 - wy0
+ }
+
+ // Middle-mouse drag
+ mx, my := ebiten.CursorPosition()
+ if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonMiddle) {
+ c.dragActive = true
+ c.dragStartScreenX = mx
+ c.dragStartScreenY = my
+ c.dragStartCamX = c.X
+ c.dragStartCamY = c.Y
+ }
+ if c.dragActive {
+ if ebiten.IsMouseButtonPressed(ebiten.MouseButtonMiddle) {
+ c.X = c.dragStartCamX - float64(mx-c.dragStartScreenX)/zoom
+ c.Y = c.dragStartCamY - float64(my-c.dragStartScreenY)/zoom
+ } else {
+ c.dragActive = false
+ }
+ }
+
+ // Arrow key pan (velocity-based)
+ dx, dy := 0.0, 0.0
+ if ebiten.IsKeyPressed(ebiten.KeyArrowLeft) {
+ dx -= 1
+ }
+ if ebiten.IsKeyPressed(ebiten.KeyArrowRight) {
+ dx += 1
+ }
+ if ebiten.IsKeyPressed(ebiten.KeyArrowUp) {
+ dy -= 1
+ }
+ if ebiten.IsKeyPressed(ebiten.KeyArrowDown) {
+ dy += 1
+ }
+ if dx != 0 && dy != 0 {
+ inv := 1.0 / math.Sqrt2
+ dx *= inv
+ dy *= inv
+ }
+ c.vx += dx * cameraScrollSpeed
+ c.vy += dy * cameraScrollSpeed
+
+ // Apply velocity and exponential drag
+ c.X += c.vx * dt
+ c.Y += c.vy * dt
+ decay := math.Exp(-cameraDrag * dt)
+ c.vx *= decay
+ c.vy *= decay
+
+ c.clampToBounds()
+}