diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-06-06 20:59:12 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-06-06 20:59:12 +0200 |
| commit | d9a54f1319cc9dcc2861cecc9edfde14cf039c5b (patch) | |
| tree | 74df791d6eda0ffe6c09cb32e5ffd76c4849cbac /engine | |
| parent | 18592fd9f520a33ab307e496b995c1a7bf284893 (diff) | |
Add examples catalog with subcommand dispatch
- Extract game engine from package main to engine/ library package
- Create examples/ package with registry, Entry type, and BuilderDeps
- Register default world as an example (moved from main())
- Add combat example: two teams of units facing each other
- Rewrite main.go as thin dispatch: go run . example [name]
- Update README with examples catalog one-liner
Diffstat (limited to 'engine')
| -rw-r--r-- | engine/camera.go | 174 | ||||
| -rw-r--r-- | engine/draw.go | 127 | ||||
| -rw-r--r-- | engine/entity.go | 107 | ||||
| -rw-r--r-- | engine/game.go | 180 | ||||
| -rw-r--r-- | engine/gather_order.go | 80 | ||||
| -rw-r--r-- | engine/image.go | 33 | ||||
| -rw-r--r-- | engine/input.go | 36 | ||||
| -rw-r--r-- | engine/integration_test.go | 201 | ||||
| -rw-r--r-- | engine/order.go | 41 | ||||
| -rw-r--r-- | engine/patrol_order.go | 46 | ||||
| -rw-r--r-- | engine/resource.go | 20 | ||||
| -rw-r--r-- | engine/spawn.go | 53 |
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 + } + } + } +} |
