diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-05-07 10:31:42 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-05-07 10:31:42 +0200 |
| commit | b2ca89d8193d98aa21e9637257c3e7a7216b9bd8 (patch) | |
| tree | e87be16d5a92ad88070f3c96976174cab683f571 | |
| parent | 2e8da8cb6948664a77fa73cb0c66e0cc27419dc5 (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>
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | camera.go | 174 | ||||
| -rw-r--r-- | draw.go | 38 | ||||
| -rw-r--r-- | integration_test.go | 62 | ||||
| -rw-r--r-- | main.go | 19 | ||||
| -rw-r--r-- | selection/selection.go | 17 |
6 files changed, 288 insertions, 26 deletions
@@ -12,8 +12,7 @@ - [ ] see the current action (debug?) - [ ] spawner: show queue - [ ] keyboard config -- [ ] bigger map -- [ ] move the map view +- [ ] display selected entity name in bottom banner - [ ] ecs array: on delete, replace by last and update id->index map - [ ] collision based move w smaller accuracy - [ ] perf: look for nil comparison -> more data oriented @@ -26,6 +25,7 @@ - [ ] getMoveMap could be a maintained index - [ ] entityAt could be a maintained index - [ ] KeyPatrolRequest shortcut should be Q +- [ ] mise task manager ## Bugs 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() +} @@ -20,10 +20,13 @@ func (g *Game) font(size float64) *text.GoTextFace { } func (g *Game) Draw(screen *ebiten.Image) { - x, y := ebiten.CursorPosition() - cursor := physics.Point{X: x, Y: y} + sx, sy := ebiten.CursorPosition() + cursor := g.Camera.ScreenToWorld(sx, sy) + screenCursor := physics.Point{X: sx, Y: sy} screen.Fill(soilColor) + camGeoM := g.Camera.GeoM() + required := ecs.CM_Image | ecs.CM_Position for i, mask := range g.Mask { if mask&required == required { @@ -31,18 +34,21 @@ func (g *Game) Draw(screen *ebiten.Image) { image := g.Image[i] op := &ebiten.DrawImageOptions{} op.GeoM.Translate(float64(position.X), float64(position.Y)) + op.GeoM.Concat(camGeoM) screen.DrawImage(image, op) if mask&ecs.CM_Selectable == ecs.CM_Selectable { selectable := g.Selectable[i] if selectable.IsSelected { opt := &ebiten.DrawImageOptions{} opt.GeoM.Translate(float64(position.X-selection.HaloWidth/2), float64(position.Y-selection.HaloWidth/2)) + opt.GeoM.Concat(camGeoM) screen.DrawImage(selectable.Halo, opt) if mask&ecs.CM_ResourceSource == ecs.CM_ResourceSource { source := g.ResourceSource[i] resourceText := fmt.Sprintf("%d", source.Remaining) op := &text.DrawOptions{} op.GeoM.Translate(float64(position.X+5), float64(position.Y+30)) + op.GeoM.Concat(camGeoM) op.ColorScale.ScaleWithColor(color.White) text.Draw(screen, resourceText, g.font(40), op) } @@ -51,20 +57,26 @@ func (g *Game) Draw(screen *ebiten.Image) { spawnText := fmt.Sprintf("%d", len(spawn.Requests)) op := &text.DrawOptions{} op.GeoM.Translate(float64(position.X+5), float64(position.Y+70)) + op.GeoM.Concat(camGeoM) op.ColorScale.ScaleWithColor(color.White) text.Draw(screen, spawnText, g.font(40), op) if spawn.SpawnTarget.Ok { spawnTarget := spawn.SpawnTarget.Value - vector.DrawFilledCircle(screen, float32(spawnTarget.X+50), float32(spawnTarget.Y+50), 30, Red, true) + ssx, ssy := g.Camera.WorldToScreen(spawnTarget) + offset := 50 * float32(g.Camera.Zoom) + vector.DrawFilledCircle(screen, float32(ssx)+offset, float32(ssy)+offset, 30*float32(g.Camera.Zoom), Red, true) } } if mask&ecs.CM_Move == ecs.CM_Move { move := g.Move[i] - dx := +image.Bounds().Dx() / 2 - dy := +image.Bounds().Dy() / 2 + dx := image.Bounds().Dx() / 2 + dy := image.Bounds().Dy() / 2 last := position + strokeW := 10.0 * float32(g.Camera.Zoom) for _, point := range move.Path { - vector.StrokeLine(screen, float32(last.X+dx), float32(last.Y+dy), float32(point.X+dx), float32(point.Y+dy), 10.0, color.RGBA{256 * 3 / 16, 256 * 3 / 16, 256 * 3 / 16, 256 / 4}, true) + lsx, lsy := g.Camera.WorldToScreen(physics.Point{X: last.X + dx, Y: last.Y + dy}) + psx, psy := g.Camera.WorldToScreen(physics.Point{X: point.X + dx, Y: point.Y + dy}) + vector.StrokeLine(screen, float32(lsx), float32(lsy), float32(psx), float32(psy), strokeW, color.RGBA{256 * 3 / 16, 256 * 3 / 16, 256 * 3 / 16, 256 / 4}, true) last = point } } @@ -73,7 +85,7 @@ func (g *Game) Draw(screen *ebiten.Image) { } } if g.GlobalSelection.IsActive(cursor) { - g.GlobalSelection.Draw(screen, cursor) + g.GlobalSelection.Draw(screen, screenCursor) } drawTopBanner(screen, g) @@ -85,18 +97,18 @@ func (g *Game) Draw(screen *ebiten.Image) { func drawTopBanner(screen *ebiten.Image, g *Game) { screenBounds := screen.Bounds() - bannerHeight := float32(200) + bannerHeight := float32(50) vector.DrawFilledRect(screen, 0, 0, float32(screenBounds.Dx()), bannerHeight, color.White, true) resourceText := fmt.Sprintf("Resources: %d", g.ResourceAmount) op := &text.DrawOptions{} - op.GeoM.Translate(25, 25) + op.GeoM.Translate(8, 6) op.ColorScale.ScaleWithColor(color.Black) - text.Draw(screen, resourceText, g.font(100), op) + text.Draw(screen, resourceText, g.font(30), op) } func drawBottomBanner(screen *ebiten.Image, g *Game) { screenBounds := screen.Bounds() - bannerHeight := float32(200) + bannerHeight := float32(40) bannerTop := screenBounds.Dy() - int(bannerHeight) vector.DrawFilledRect(screen, 0, float32(bannerTop), float32(screenBounds.Dx()), bannerHeight, color.White, true) @@ -109,7 +121,7 @@ func drawBottomBanner(screen *ebiten.Image, g *Game) { } op := &text.DrawOptions{} - op.GeoM.Translate(25, float64(float32(bannerTop)+25)) + op.GeoM.Translate(8, float64(float32(bannerTop)+6)) op.ColorScale.ScaleWithColor(color.Black) - text.Draw(screen, shortcuts, g.font(60), op) + text.Draw(screen, shortcuts, g.font(22), op) } 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{} @@ -32,6 +32,7 @@ var soilColor = color.RGBA{0x60, 0x40, 0x20, 0xff} type Game struct { CurrentAction Action + Camera Camera GlobalSelection selection.GlobalSelection ResourceAmount int FaceSource *text.GoTextFaceSource @@ -83,7 +84,7 @@ func (g *Game) getMoveMap() physics.MoveMap { blocked[g.Position[i]] = true } } - return physics.MoveMap{Width: 3200, Height: 2400, Blocked: blocked} + return physics.MoveMap{Width: WorldWidth, Height: WorldHeight, Blocked: blocked} } func (g *Game) entityAt(position physics.Point) ecs.Entity { @@ -119,6 +120,8 @@ func (g *Game) updateSelecting(cursor physics.Point, moveMap physics.MoveMap) { } if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { g.GlobalSelection.Start(cursor) + sx, sy := ebiten.CursorPosition() + g.GlobalSelection.StartScreen(physics.Point{X: sx, Y: sy}) } if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight) { destination := cursor.Div(100).Mul(100) @@ -151,8 +154,9 @@ func (g *Game) updateSelecting(cursor physics.Point, moveMap physics.MoveMap) { } func (g *Game) Update() error { - x, y := ebiten.CursorPosition() - cursor := physics.Point{X: x, Y: y} + g.Camera.Update(1.0 / 60.0) + sx, sy := ebiten.CursorPosition() + cursor := g.Camera.ScreenToWorld(sx, sy) if inpututil.IsKeyJustReleased(ebiten.KeyEscape) { g.CurrentAction = Selecting slog.Info("selecting action") @@ -179,7 +183,7 @@ func (g *Game) Update() error { } func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { - return 3200, 2400 + return ScreenWidth, ScreenHeight } func main() { @@ -194,7 +198,7 @@ func main() { } logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) slog.SetDefault(slog.New(logHandler)) - ebiten.SetWindowSize(960, 640) + ebiten.SetWindowSize(ScreenWidth, ScreenHeight) ebiten.SetWindowTitle("Age of Empire") icon, err := os.Open("icon-crop.jpg") kcore.Expect(err, "failed to open icon") @@ -204,6 +208,11 @@ func main() { ebiten.SetWindowIcon([]image.Image{iconImg}) game := &Game{ ResourceAmount: 150, + Camera: Camera{ + X: 1000, + Y: 1000, + Zoom: 1.0, + }, } fontData, err := os.ReadFile("fonts/Cinzel-VariableFont_wght.ttf") kcore.Expect(err, "failed to read font") diff --git a/selection/selection.go b/selection/selection.go index e4afdaa..1c702ee 100644 --- a/selection/selection.go +++ b/selection/selection.go @@ -32,10 +32,11 @@ type selectItem struct { } type GlobalSelection struct { - isActive bool - start physics.Point - buffer []selectItem - Selected []ecs.Entity + isActive bool + start physics.Point + startScreen physics.Point + buffer []selectItem + Selected []ecs.Entity } func (s GlobalSelection) IsActive(cursor physics.Point) bool { @@ -56,6 +57,10 @@ func (s *GlobalSelection) Start(point physics.Point) { s.start = point } +func (s *GlobalSelection) StartScreen(point physics.Point) { + s.startScreen = point +} + type SelectTarget struct { Selectable *Selectable Position physics.Point @@ -134,6 +139,6 @@ func (s *GlobalSelection) Unselect(selectables []Selectable) { s.Selected = []ecs.Entity{} } -func (s *GlobalSelection) Draw(dst *ebiten.Image, cursor physics.Point) { - vector.StrokeRect(dst, float32(s.start.X), float32(s.start.Y), float32(cursor.X-s.start.X), float32(cursor.Y-s.start.Y), 10.0, color.RGBA{256 * 3 / 16, 256 * 3 / 16, 256 * 3 / 16, 256 / 4}, true) +func (s *GlobalSelection) Draw(dst *ebiten.Image, screenCursor physics.Point) { + vector.StrokeRect(dst, float32(s.startScreen.X), float32(s.startScreen.Y), float32(screenCursor.X-s.startScreen.X), float32(screenCursor.Y-s.startScreen.Y), 10.0, color.RGBA{256 * 3 / 16, 256 * 3 / 16, 256 * 3 / 16, 256 / 4}, true) } |
