aboutsummaryrefslogtreecommitdiff
path: root/engine
diff options
context:
space:
mode:
Diffstat (limited to 'engine')
-rw-r--r--engine/camera.go174
-rw-r--r--engine/draw.go127
-rw-r--r--engine/entity.go107
-rw-r--r--engine/game.go180
-rw-r--r--engine/gather_order.go80
-rw-r--r--engine/image.go33
-rw-r--r--engine/input.go36
-rw-r--r--engine/integration_test.go201
-rw-r--r--engine/order.go41
-rw-r--r--engine/patrol_order.go46
-rw-r--r--engine/resource.go20
-rw-r--r--engine/spawn.go53
12 files changed, 1098 insertions, 0 deletions
diff --git a/engine/camera.go b/engine/camera.go
new file mode 100644
index 0000000..867aaf4
--- /dev/null
+++ b/engine/camera.go
@@ -0,0 +1,174 @@
+package engine
+
+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/engine/draw.go b/engine/draw.go
new file mode 100644
index 0000000..ca605ce
--- /dev/null
+++ b/engine/draw.go
@@ -0,0 +1,127 @@
+package engine
+
+import (
+ "fmt"
+ "image/color"
+
+ "age_of_empires/ecs"
+ "age_of_empires/physics"
+ "age_of_empires/selection"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/text/v2"
+ "github.com/hajimehoshi/ebiten/v2/vector"
+)
+
+func (g *Game) font(size float64) *text.GoTextFace {
+ f := &text.GoTextFace{Source: g.FaceSource, Size: size}
+ f.SetVariation(text.MustParseTag("wght"), 500)
+ return f
+}
+
+func (g *Game) Draw(screen *ebiten.Image) {
+ 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 {
+ position := g.Position[i]
+ 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)
+ }
+ if mask&ecs.CM_Spawn == ecs.CM_Spawn {
+ spawn := g.Spawn[i]
+ 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
+ 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
+ last := position
+ strokeW := 10.0 * float32(g.Camera.Zoom)
+ for _, point := range move.Path {
+ 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
+ }
+ }
+ }
+ }
+ }
+ }
+ if g.GlobalSelection.IsActive(cursor) {
+ g.GlobalSelection.Draw(screen, screenCursor)
+ }
+
+ drawTopBanner(screen, g)
+
+ if len(g.GlobalSelection.Selected) > 0 {
+ drawBottomBanner(screen, g)
+ }
+}
+
+func drawTopBanner(screen *ebiten.Image, g *Game) {
+ screenBounds := screen.Bounds()
+ 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(8, 6)
+ op.ColorScale.ScaleWithColor(color.Black)
+ text.Draw(screen, resourceText, g.font(30), op)
+}
+
+func drawBottomBanner(screen *ebiten.Image, g *Game) {
+ screenBounds := screen.Bounds()
+ bannerHeight := float32(40)
+ bannerTop := screenBounds.Dy() - int(bannerHeight)
+ vector.DrawFilledRect(screen, 0, float32(bannerTop), float32(screenBounds.Dx()), bannerHeight, color.White, true)
+
+ shortcuts := "[Right-click] Action [Q] Patrol [Esc] Deselect"
+ for _, i := range g.GlobalSelection.Selected {
+ if g.Mask[i]&ecs.CM_Spawn == ecs.CM_Spawn {
+ shortcuts += " [S] Spawn unit"
+ break
+ }
+ }
+
+ op := &text.DrawOptions{}
+ op.GeoM.Translate(8, float64(float32(bannerTop)+6))
+ op.ColorScale.ScaleWithColor(color.Black)
+ text.Draw(screen, shortcuts, g.font(22), op)
+}
diff --git a/engine/entity.go b/engine/entity.go
new file mode 100644
index 0000000..7aa43b1
--- /dev/null
+++ b/engine/entity.go
@@ -0,0 +1,107 @@
+package engine
+
+import (
+ "image/color"
+
+ "age_of_empires/ecs"
+ "age_of_empires/physics"
+ "age_of_empires/selection"
+
+ "github.com/hajimehoshi/ebiten/v2"
+)
+
+type Components struct {
+ Position physics.Point
+ RelBounds physics.Rectangle
+ Image *ebiten.Image
+ Selectable selection.Selectable
+ Move physics.Move
+ Order OrderKind
+ ResourceGatherer ResourceGatherer
+ ResourceSource ResourceSource
+ ResourceStorage ResourceStorage
+ Spawn Spawn
+}
+
+type EntityBuilder struct {
+ mask ecs.Mask
+ components Components
+}
+
+func NewEntityBuilder() EntityBuilder {
+ return EntityBuilder{
+ components: Components{},
+ }
+}
+
+func (b EntityBuilder) WithPosition(position physics.Point) EntityBuilder {
+ b.components.Position = position
+ b.mask |= ecs.CM_Position
+ return b
+}
+
+func (b EntityBuilder) WithSolid(image *ebiten.Image) EntityBuilder {
+ b.components.Image = image
+ b.mask |= ecs.CM_Image
+ b.components.RelBounds = image.Bounds()
+ b.mask |= ecs.CM_RelBounds
+ return b
+}
+
+var Red = color.RGBA{0xff, 0x00, 0x00, 0xff}
+
+func (b EntityBuilder) WithSelectable(haloKind string, priority selection.Priority) EntityBuilder {
+ b.components.Selectable = selection.Selectable{
+ IsSelected: false,
+ Halo: nil,
+ Priority: priority,
+ }
+ b.mask |= ecs.CM_Selectable
+ switch haloKind {
+ case "round":
+ b.components.Selectable.Halo = NewStrokeCircleImage(110, selection.HaloWidth, Red)
+ case "square":
+ b.components.Selectable.Halo = NewStrokeRectangleImage(physics.Point{X: 110, Y: 110}, selection.HaloWidth, Red)
+ }
+ return b
+}
+
+func (b EntityBuilder) WithResourceStorage() EntityBuilder {
+ b.components.ResourceStorage = ResourceStorage{}
+ b.mask |= ecs.CM_ResourceStorage
+ return b
+}
+
+func (b EntityBuilder) WithResourceSource(amount int) EntityBuilder {
+ b.components.ResourceSource = ResourceSource{Remaining: amount}
+ b.mask |= ecs.CM_ResourceSource
+ return b
+}
+
+func (b EntityBuilder) WithSpawn(spawn Spawn) EntityBuilder {
+ b.components.Spawn = spawn
+ b.mask |= ecs.CM_Spawn
+ return b
+}
+
+func (b EntityBuilder) WithMove() EntityBuilder {
+ b.components.Move = physics.Move{IsActive: false}
+ b.mask |= ecs.CM_Move
+ return b
+}
+
+func (b EntityBuilder) WithOrder() EntityBuilder {
+ b.components.Order = OrderKindNone
+ b.mask |= ecs.CM_Order
+ return b
+}
+
+func (b EntityBuilder) WithResourceGatherer(maxCapacity int) EntityBuilder {
+ b.components.ResourceGatherer = ResourceGatherer{MaxCapacity: maxCapacity}
+ b.mask |= ecs.CM_ResourceGatherer
+ return b
+}
+
+func (b EntityBuilder) Build() (Components, ecs.Mask) {
+ return b.components, b.mask
+}
diff --git a/engine/game.go b/engine/game.go
new file mode 100644
index 0000000..c5699d2
--- /dev/null
+++ b/engine/game.go
@@ -0,0 +1,180 @@
+package engine
+
+import (
+ "image/color"
+ "time"
+
+ "age_of_empires/ecs"
+ "age_of_empires/physics"
+ "age_of_empires/selection"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/text/v2"
+
+ "golang.org/x/exp/slog"
+)
+
+type Action string
+
+const (
+ Selecting Action = "selecting"
+ Patrolling Action = "patrolling"
+)
+
+var soilColor = color.RGBA{0x60, 0x40, 0x20, 0xff}
+
+type Game struct {
+ CurrentAction Action
+ Camera Camera
+ GlobalSelection selection.GlobalSelection
+ ResourceAmount int
+ FaceSource *text.GoTextFaceSource
+ UnitBuilder EntityBuilder
+ NowFunc func() time.Time
+ // Components
+ Mask []ecs.Mask
+ // Entities []*Entity
+ Position []physics.Point
+ RelBounds []physics.Rectangle
+ Image []*ebiten.Image
+ Selectable []selection.Selectable
+ Move []physics.Move
+ Order []OrderKind
+ PatrolOrder []PatrolOrder
+ ResourceGatherer []ResourceGatherer
+ ResourceSource []ResourceSource
+ ResourceStorage []ResourceStorage
+ Spawn []Spawn
+}
+
+func (g Game) Now() time.Time {
+ if g.NowFunc != nil {
+ return g.NowFunc()
+ }
+ return time.Now()
+}
+
+func (g *Game) Append(components Components, mask ecs.Mask) ecs.Entity {
+ g.Mask = append(g.Mask, mask)
+ g.Position = append(g.Position, components.Position)
+ g.RelBounds = append(g.RelBounds, components.RelBounds)
+ g.Image = append(g.Image, components.Image)
+ g.Selectable = append(g.Selectable, components.Selectable)
+ g.Move = append(g.Move, components.Move)
+ g.Order = append(g.Order, components.Order)
+ g.ResourceGatherer = append(g.ResourceGatherer, components.ResourceGatherer)
+ g.ResourceSource = append(g.ResourceSource, components.ResourceSource)
+ g.ResourceStorage = append(g.ResourceStorage, components.ResourceStorage)
+ g.Spawn = append(g.Spawn, components.Spawn)
+ return ecs.Entity(len(g.Mask) - 1)
+}
+
+func (g *Game) GetMoveMap() physics.MoveMap {
+ required := ecs.CM_Position
+ blocked := map[physics.Point]bool{}
+ for i, mask := range g.Mask {
+ if mask&required == required {
+ blocked[g.Position[i]] = true
+ }
+ }
+ return physics.MoveMap{Width: WorldWidth, Height: WorldHeight, Blocked: blocked}
+}
+
+func (g *Game) entityAt(position physics.Point) ecs.Entity {
+ required := ecs.CM_Position
+ for i, mask := range g.Mask {
+ if mask&required == required && g.Position[i] == position {
+ return ecs.Entity(i)
+ }
+ }
+ return -1
+}
+
+func getAllStorageDockings(g *Game) []physics.Point {
+ storageDockings := []physics.Point{}
+ required := ecs.CM_Position | ecs.CM_ResourceStorage
+ for i, mask := range g.Mask {
+ if mask&required == required {
+ position := g.Position[i]
+ storageDockings = append(storageDockings, physics.AdjacentPoints(position)...)
+ }
+ }
+ return storageDockings
+}
+
+const (
+ KeySpawnRequest = ebiten.KeyS
+ KeyPatrolRequest = ebiten.KeyQ
+)
+
+func (g *Game) updateSelecting(input Input, moveMap physics.MoveMap) {
+ if input.LeftMouseDown {
+ g.GlobalSelection.Start(input.Cursor)
+ g.GlobalSelection.StartScreen(input.ScreenCursor)
+ }
+ if input.RightMouseUp {
+ destination := input.Cursor.Div(100).Mul(100)
+ slog.Info("destination", slog.String("destination", destination.String()))
+ for _, i := range g.GlobalSelection.Selected {
+ MainAction(g, i, destination, moveMap)
+ }
+ }
+ if input.LeftMouseUp {
+ if g.GlobalSelection.IsActive(input.Cursor) {
+ if g.GlobalSelection.IsArea(input.Cursor) {
+ g.GlobalSelection.SelectMultiple(input.Cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
+ } else {
+ g.GlobalSelection.SelectSingle(input.Cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
+ }
+ }
+ }
+ if input.SpawnPressed {
+ required := ecs.CM_Spawn
+ for _, i := range g.GlobalSelection.Selected {
+ if g.Mask[i]&required == required {
+ spawn := &g.Spawn[i]
+ if g.ResourceAmount >= spawn.UnitResourceCost {
+ g.ResourceAmount -= spawn.UnitResourceCost
+ spawn.Requests = append(spawn.Requests, SpawnRequest{Start: g.Now()})
+ }
+ }
+ }
+ }
+}
+
+func (g *Game) UpdateSimulation(input Input) {
+ moveMap := g.GetMoveMap()
+
+ if input.EscapePressed {
+ g.GlobalSelection.Unselect(g.Selectable)
+ g.CurrentAction = Selecting
+ } else if input.PatrolPressed {
+ g.CurrentAction = Patrolling
+ }
+
+ switch g.CurrentAction {
+ case Selecting:
+ g.updateSelecting(input, moveMap)
+ case Patrolling:
+ if input.RightMouseUp {
+ destination := input.Cursor.Div(100).Mul(100)
+ StartPatrolSystem(destination, g.Mask, g.Position, g.Move, g.Order, g.PatrolOrder)
+ g.CurrentAction = Selecting
+ }
+ }
+
+ physics.UpdateMoveSystem(moveMap, g.Mask, g.Move, g.Position)
+ UpdateSpawnSystem(g, g.Now(), g.Mask, g.Spawn, g.Position)
+ UpdateGatherSystem(g, g.Now(), g.GetMoveMap(), g.Mask, g.Order, g.Position, g.Move, g.ResourceGatherer)
+ UpdatePatrolSystem(g.GetMoveMap(), g.Mask, g.Order, g.PatrolOrder, g.Position, g.Move)
+}
+
+func (g *Game) Update() error {
+ g.Camera.Update(1.0 / 60.0)
+ g.UpdateSimulation(CollectInput(&g.Camera))
+ return nil
+}
+
+func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
+ return ScreenWidth, ScreenHeight
+}
diff --git a/engine/gather_order.go b/engine/gather_order.go
new file mode 100644
index 0000000..e0c71fb
--- /dev/null
+++ b/engine/gather_order.go
@@ -0,0 +1,80 @@
+package engine
+
+import (
+ "math"
+ "time"
+
+ "age_of_empires/ecs"
+ "age_of_empires/physics"
+
+ "golang.org/x/exp/slog"
+)
+
+func UpdateGatherSystem(g *Game, now time.Time, moveMap physics.MoveMap, masks []ecs.Mask, orders []OrderKind, positions []physics.Point, moves []physics.Move, gatherers []ResourceGatherer) {
+ required := ecs.CM_ResourceGatherer | ecs.CM_Position | ecs.CM_Move | ecs.CM_Order
+ for i, mask := range masks {
+ if mask&required == required {
+ order := &orders[i]
+ position := positions[i]
+ move := &moves[i]
+ gatherer := &gatherers[i]
+ if *order == OrderKindGather {
+ if gatherer.CurrentVolume == gatherer.MaxCapacity {
+ updateGathererFull(g, getAllStorageDockings(g), moveMap, gatherer, order, move, position)
+ } else if physics.Distance(position, g.Position[gatherer.Source]) <= 100 {
+ updateGathering(now, gatherer, &g.ResourceSource[gatherer.Source])
+ } else if !move.IsActive {
+ startMoveToSource(moveMap, gatherer, move, order, position, g.Position[gatherer.Source])
+ }
+ }
+ }
+ }
+}
+
+func updateGathering(now time.Time, gatherer *ResourceGatherer, source *ResourceSource) {
+ if gatherer.CurrentVolume >= gatherer.MaxCapacity || gatherer.LastPickupTime.Add(200*time.Millisecond).After(now) {
+ return
+ }
+ gatherer.LastPickupTime = now
+ gatherer.CurrentVolume += 1
+ source.Remaining -= 1
+}
+
+func updateGathererFull(g *Game, storageDockings []physics.Point, moveMap physics.MoveMap, gatherer *ResourceGatherer, order *OrderKind, move *physics.Move, position physics.Point) {
+ if move.IsActive {
+ return
+ }
+ destination, distance := physics.Closest(position, storageDockings, moveMap)
+ if distance == math.MaxInt {
+ slog.Info("no accessible storage, canceling gather order")
+ *order = OrderKindNone
+ return
+ }
+ slog.Info("storage target", slog.String("storage", destination.String()), slog.Int("distance", distance))
+ if distance > 1 {
+ slog.Info("moving to storage", slog.String("storage", destination.String()))
+ *move = physics.NewMove(position, destination, moveMap)
+ return
+ }
+ g.ResourceAmount += gatherer.CurrentVolume
+ gatherer.CurrentVolume = 0
+ startMoveToSource(moveMap, gatherer, move, order, position, g.Position[gatherer.Source])
+}
+
+func startMoveToSource(moveMap physics.MoveMap, gatherer *ResourceGatherer, move *physics.Move, order *OrderKind, position physics.Point, sourcePosition physics.Point) {
+ sourceDockings := physics.AdjacentPoints(sourcePosition)
+ destination, distance := physics.Closest(position, sourceDockings, moveMap)
+ if distance == math.MaxInt {
+ slog.Info("no accessible storage, canceling gather order")
+ *order = OrderKindNone
+ return
+ }
+ *move = physics.NewMove(position, destination, moveMap)
+}
+
+func Gather(moveMap physics.MoveMap, position physics.Point, move *physics.Move, order *OrderKind, gatherer *ResourceGatherer, source ecs.Entity, sourcePosition physics.Point) {
+ slog.Info("gathering from", slog.String("source", sourcePosition.String()))
+ *order = OrderKindGather
+ gatherer.Source = source
+ startMoveToSource(moveMap, gatherer, move, order, position, sourcePosition)
+}
diff --git a/engine/image.go b/engine/image.go
new file mode 100644
index 0000000..07cac55
--- /dev/null
+++ b/engine/image.go
@@ -0,0 +1,33 @@
+package engine
+
+import (
+ "age_of_empires/physics"
+ "image/color"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/vector"
+)
+
+func NewFilledRectangleImage(size physics.Point, color color.Color) *ebiten.Image {
+ image := ebiten.NewImage(size.X, size.Y)
+ image.Fill(color)
+ return image
+}
+
+func NewStrokeRectangleImage(size physics.Point, strokeWidth float32, color color.Color) *ebiten.Image {
+ image := ebiten.NewImage(size.X, size.Y)
+ vector.StrokeRect(image, 0, 0, float32(size.X), float32(size.Y), float32(strokeWidth), color, true)
+ return image
+}
+
+func NewFilledCircleImage(width int, color color.Color) *ebiten.Image {
+ image := ebiten.NewImage((width), (width))
+ vector.DrawFilledCircle(image, float32(width/2), float32(width/2), float32(width/2), color, true)
+ return image
+}
+
+func NewStrokeCircleImage(width int, strokeWidth int, color color.Color) *ebiten.Image {
+ image := ebiten.NewImage((width), (width))
+ vector.StrokeCircle(image, float32(width/2), float32(width/2), float32(width/2), float32(strokeWidth), color, true)
+ return image
+}
diff --git a/engine/input.go b/engine/input.go
new file mode 100644
index 0000000..5d03b08
--- /dev/null
+++ b/engine/input.go
@@ -0,0 +1,36 @@
+package engine
+
+import (
+ "age_of_empires/physics"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/inpututil"
+)
+
+type Input struct {
+ Cursor physics.Point
+ ScreenCursor physics.Point
+
+ EscapePressed bool
+ PatrolPressed bool
+
+ LeftMouseDown bool
+ LeftMouseUp bool
+ RightMouseUp bool
+
+ SpawnPressed bool
+}
+
+func CollectInput(camera *Camera) Input {
+ sx, sy := ebiten.CursorPosition()
+ return Input{
+ Cursor: camera.ScreenToWorld(sx, sy),
+ ScreenCursor: physics.Point{X: sx, Y: sy},
+ EscapePressed: inpututil.IsKeyJustReleased(ebiten.KeyEscape),
+ PatrolPressed: inpututil.IsKeyJustReleased(KeyPatrolRequest),
+ LeftMouseDown: inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft),
+ LeftMouseUp: inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft),
+ RightMouseUp: inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight),
+ SpawnPressed: inpututil.IsKeyJustReleased(KeySpawnRequest),
+ }
+}
diff --git a/engine/integration_test.go b/engine/integration_test.go
new file mode 100644
index 0000000..b999b45
--- /dev/null
+++ b/engine/integration_test.go
@@ -0,0 +1,201 @@
+package engine
+
+import (
+ "image/color"
+ "testing"
+ "time"
+
+ "age_of_empires/physics"
+ "age_of_empires/selection"
+
+ "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{}
+
+ // Town Center (Storage) at -100,0 (different grid cell from unit at 0,0)
+ tcBuilder := NewEntityBuilder().
+ WithPosition(physics.Point{X: -100, Y: 0}).
+ WithResourceStorage()
+ _ = game.Append(tcBuilder.Build())
+
+ // Mine (Source) at 0, 200
+ mineBuilder := NewEntityBuilder().
+ WithPosition(physics.Point{X: 0, Y: 200}).
+ WithResourceSource(100)
+ mineID := game.Append(mineBuilder.Build())
+
+ // Unit (Gatherer) at 0, 0
+ unitBuilder := NewEntityBuilder().
+ WithPosition(physics.Point{X: 0, Y: 0}).
+ WithSolid(NewFilledCircleImage(100, color.White)).
+ WithSelectable("round", selection.Unit).
+ WithMove().
+ WithOrder().
+ WithResourceGatherer(10)
+ game.Append(unitBuilder.Build())
+
+ // Set up simulated time
+ simTime := time.Now()
+ game.NowFunc = func() time.Time { return simTime }
+
+ // Click unit to select, then right-click mine — same gesture as in-game
+ game.UpdateSimulation(Input{EscapePressed: true})
+ game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: 50}, LeftMouseDown: true})
+ game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: 50}, LeftMouseUp: true})
+ game.UpdateSimulation(Input{Cursor: game.Position[mineID], RightMouseUp: true})
+
+ initialResources := game.ResourceAmount
+ dt := 16 * time.Millisecond
+ maxTicks := 100_000
+
+ deposited := false
+
+ for i := 0; i < maxTicks; i++ {
+ simTime = simTime.Add(dt)
+ game.UpdateSimulation(Input{})
+
+ if game.ResourceAmount > initialResources {
+ deposited = true
+ break
+ }
+ }
+
+ if !deposited {
+ t.Errorf("Unit failed to gather resources within %d ticks. ResourceAmount: %d", maxTicks, game.ResourceAmount)
+ }
+}
+
+func TestTwoEntitiesMovingToSameCellDontOverlap(t *testing.T) {
+ // Two units close together both ordered to the same cell should not overlap.
+ game := &Game{}
+
+ target := physics.Point{X: 500, Y: 0}
+ unitImg := NewFilledCircleImage(100, color.White)
+
+ unitA := game.Append(NewEntityBuilder().
+ WithPosition(physics.Point{X: 200, Y: 0}).
+ WithSolid(unitImg).WithSelectable("round", selection.Unit).
+ WithMove().WithOrder().Build())
+ unitB := game.Append(NewEntityBuilder().
+ WithPosition(physics.Point{X: 100, Y: 0}).
+ WithSolid(unitImg).WithSelectable("round", selection.Unit).
+ WithMove().WithOrder().Build())
+
+ // Area-select both units then right-click target — same gesture as in-game
+ game.UpdateSimulation(Input{EscapePressed: true})
+ game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: -50}, LeftMouseDown: true})
+ game.UpdateSimulation(Input{Cursor: physics.Point{X: 350, Y: 50}, LeftMouseUp: true})
+ game.UpdateSimulation(Input{Cursor: target, RightMouseUp: true})
+
+ // MOVE_SPEED=10, cell=100 → 10 ticks per cell. B detours around A → ~60 ticks.
+ for range 60 {
+ game.UpdateSimulation(Input{})
+ }
+
+ assert.False(t, game.Move[unitA].IsActive, "Entity A should have stopped moving")
+ assert.False(t, game.Move[unitB].IsActive, "Entity B should have stopped moving")
+ assert.NotEqual(t, game.Position[unitA], game.Position[unitB],
+ "Entity A and B should NOT overlap: A at %s, B at %s", game.Position[unitA], game.Position[unitB])
+}
+
+func TestUnitDoesNotStopWhenDestinationOccupied(t *testing.T) {
+ // Unit B is far away, unit A is close. Both ordered to the same cell.
+ // A arrives first and settles. B should repath to an adjacent free cell
+ // rather than stopping mid-journey.
+ game := &Game{}
+ game.CurrentAction = Selecting
+ unitImg := NewFilledCircleImage(100, color.White)
+
+ target := physics.Point{X: 500, Y: 0}
+
+ unitA := game.Append(NewEntityBuilder().
+ WithPosition(physics.Point{X: 400, Y: 0}).
+ WithSolid(unitImg).WithSelectable("round", selection.Unit).
+ WithMove().WithOrder().Build())
+ unitB := game.Append(NewEntityBuilder().
+ WithPosition(physics.Point{X: 0, Y: 0}).
+ WithSolid(unitImg).WithSelectable("round", selection.Unit).
+ WithMove().WithOrder().Build())
+
+ game.UpdateSimulation(Input{EscapePressed: true})
+ game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: -50}, LeftMouseDown: true})
+ game.UpdateSimulation(Input{Cursor: physics.Point{X: 450, Y: 50}, LeftMouseUp: true})
+ game.UpdateSimulation(Input{Cursor: target, RightMouseUp: true})
+
+ // Run long enough for both units to settle (B travels ~5 cells)
+ for range 100 {
+ game.UpdateSimulation(Input{})
+ }
+
+ assert.False(t, game.Move[unitA].IsActive, "unit A should have stopped")
+ assert.False(t, game.Move[unitB].IsActive, "unit B should have stopped near target, not mid-journey")
+ assert.NotEqual(t, game.Position[unitA], game.Position[unitB], "units should not overlap")
+ // B should be adjacent to target, not stuck far away
+ assert.LessOrEqual(t, physics.Distance(game.Position[unitB], target), 150.0,
+ "unit B should be adjacent to target, got %s", game.Position[unitB])
+}
diff --git a/engine/order.go b/engine/order.go
new file mode 100644
index 0000000..8aa991b
--- /dev/null
+++ b/engine/order.go
@@ -0,0 +1,41 @@
+package engine
+
+import (
+ "log/slog"
+
+ "age_of_empires/ecs"
+ "age_of_empires/monad"
+ "age_of_empires/physics"
+)
+
+type OrderKind int
+
+const (
+ OrderKindNone OrderKind = iota
+ OrderKindPatrol
+ OrderKindGather
+)
+
+func MainAction(g *Game, mainEntity ecs.Entity, target physics.Point, moveMap physics.MoveMap) {
+ mask := g.Mask[mainEntity]
+ targetEntity := g.entityAt(target)
+ gatherTargetRequired := ecs.CM_ResourceSource
+ mainGatherRequired := ecs.CM_ResourceGatherer | ecs.CM_Move
+ if targetEntity >= 0 && (g.Mask[targetEntity]&gatherTargetRequired == gatherTargetRequired) && (mask&mainGatherRequired == mainGatherRequired) {
+ Gather(g.GetMoveMap(), g.Position[mainEntity], &g.Move[mainEntity], &g.Order[mainEntity], &g.ResourceGatherer[mainEntity], targetEntity, g.Position[targetEntity])
+ return
+ }
+ moveRequired := ecs.CM_Move | ecs.CM_Position
+ if mask&moveRequired == moveRequired {
+ position := g.Position[mainEntity]
+ g.Order[mainEntity] = OrderKindNone
+ g.Move[mainEntity] = physics.NewMove(position, target, moveMap)
+ return
+ }
+ if mask&ecs.CM_Spawn == ecs.CM_Spawn {
+ slog.Info("Setting spawn target")
+ spawn := &g.Spawn[mainEntity]
+ spawn.SpawnTarget = monad.Some(target)
+ return
+ }
+}
diff --git a/engine/patrol_order.go b/engine/patrol_order.go
new file mode 100644
index 0000000..0889f92
--- /dev/null
+++ b/engine/patrol_order.go
@@ -0,0 +1,46 @@
+package engine
+
+import (
+ "age_of_empires/ecs"
+ "age_of_empires/physics"
+
+ "golang.org/x/exp/slog"
+)
+
+type PatrolOrder struct {
+ origin physics.Point
+ destination physics.Point
+}
+
+func UpdatePatrolSystem(moveMap physics.MoveMap, masks []ecs.Mask, orders []OrderKind, patrols []PatrolOrder, positions []physics.Point, moves []physics.Move) {
+ required := ecs.CM_Position | ecs.CM_Move | ecs.CM_Order | ecs.CM_PatrolOrder
+ for i, mask := range masks {
+ if mask&required == required {
+ order := orders[i]
+ patrol := patrols[i]
+ position := positions[i]
+ move := &moves[i]
+ if order == OrderKindPatrol && !move.IsActive {
+ if position == patrol.destination {
+ *move = physics.NewMove(position, patrol.origin, moveMap)
+ } else {
+ *move = physics.NewMove(position, patrol.destination, moveMap)
+ }
+ }
+ }
+ }
+}
+
+func StartPatrolSystem(destination physics.Point, masks []ecs.Mask, positions []physics.Point, moves []physics.Move, orders []OrderKind, patrols []PatrolOrder) {
+ required := ecs.CM_Position | ecs.CM_Order | ecs.CM_PatrolOrder
+ for i, mask := range masks {
+ if mask&required == required {
+ position := positions[i]
+ order := &orders[i]
+ patrol := &patrols[i]
+ slog.Info("patrolling between", slog.String("origin", position.String()), slog.String("destination", destination.String()))
+ *patrol = PatrolOrder{origin: position, destination: destination}
+ *order = OrderKindPatrol
+ }
+ }
+}
diff --git a/engine/resource.go b/engine/resource.go
new file mode 100644
index 0000000..406750c
--- /dev/null
+++ b/engine/resource.go
@@ -0,0 +1,20 @@
+package engine
+
+import (
+ "time"
+
+ "age_of_empires/ecs"
+)
+
+type ResourceSource struct {
+ Remaining int
+}
+
+type ResourceGatherer struct {
+ MaxCapacity int
+ CurrentVolume int
+ Source ecs.Entity
+ LastPickupTime time.Time
+}
+
+type ResourceStorage struct{}
diff --git a/engine/spawn.go b/engine/spawn.go
new file mode 100644
index 0000000..fc32a84
--- /dev/null
+++ b/engine/spawn.go
@@ -0,0 +1,53 @@
+package engine
+
+import (
+ "log/slog"
+ "time"
+
+ "age_of_empires/ecs"
+ "age_of_empires/monad"
+ "age_of_empires/physics"
+)
+
+type SpawnRequest struct {
+ Start time.Time
+}
+
+type Spawn struct {
+ UnitResourceCost int
+ UnitSpawnDuration time.Duration
+ Requests []SpawnRequest
+ SpawnTarget monad.Option[physics.Point]
+}
+
+func NewSpawn(unitResourceCost int, unitSpawnDuration time.Duration) Spawn {
+ return Spawn{
+ UnitResourceCost: unitResourceCost,
+ UnitSpawnDuration: unitSpawnDuration,
+ Requests: make([]SpawnRequest, 0),
+ }
+}
+
+func UpdateSpawnSystem(g *Game, now time.Time, masks []ecs.Mask, spawns []Spawn, positions []physics.Point) {
+ required := ecs.CM_Position | ecs.CM_Spawn
+ for i, mask := range masks {
+ if mask&required == required {
+ spawn := &spawns[i]
+ position := positions[i]
+ if len(spawn.Requests) == 0 || now.Sub(spawn.Requests[0].Start) < time.Duration(spawn.UnitSpawnDuration) {
+ continue
+ }
+ spawnPosition, _ := physics.Closest(position, physics.AdjacentPoints(position), g.GetMoveMap())
+ unit := g.Append(g.UnitBuilder.WithPosition(spawnPosition).Build())
+ slog.Info("Spawned unit")
+ if spawn.SpawnTarget.Ok {
+ slog.Info("Unit has spawn target")
+ MainAction(g, unit, spawn.SpawnTarget.Value, g.GetMoveMap())
+ }
+ spawn.Requests = spawn.Requests[1:]
+ if len(spawn.Requests) > 0 {
+ spawn.Requests[0].Start = now
+ }
+ }
+ }
+}