aboutsummaryrefslogtreecommitdiff
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
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>
-rw-r--r--README.md4
-rw-r--r--camera.go174
-rw-r--r--draw.go38
-rw-r--r--integration_test.go62
-rw-r--r--main.go19
-rw-r--r--selection/selection.go17
6 files changed, 288 insertions, 26 deletions
diff --git a/README.md b/README.md
index 8954ce6..d9b96d9 100644
--- a/README.md
+++ b/README.md
@@ -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()
+}
diff --git a/draw.go b/draw.go
index baa8a08..d035985 100644
--- a/draw.go
+++ b/draw.go
@@ -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{}
diff --git a/main.go b/main.go
index 3d538b4..2475841 100644
--- a/main.go
+++ b/main.go
@@ -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)
}