From a5fef758107888853c14a592696b45768ef56df8 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 4 Jul 2025 17:55:53 +0200 Subject: Refactor into packages --- README.md | 2 + ecs/component.go | 10 +++ entity | Bin 0 -> 10923288 bytes entity.go | 38 ++++----- gather_order.go | 19 ++--- geometry.go | 30 ------- image.go | 5 +- main.go | 84 ++++++++++++-------- move.go | 220 ---------------------------------------------------- patrol_order.go | 16 ++-- physics/geometry.go | 30 +++++++ physics/move.go | 203 ++++++++++++++++++++++++++++++++++++++++++++++++ selection.go | 8 +- 13 files changed, 340 insertions(+), 325 deletions(-) create mode 100644 ecs/component.go create mode 100755 entity delete mode 100644 geometry.go delete mode 100644 move.go create mode 100644 physics/geometry.go create mode 100644 physics/move.go diff --git a/README.md b/README.md index ad0db3f..84d4fc9 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ ## TODO +- atm, stops moving if someone settles at destination + ```go func (g \*Game) updatePlacingWall(cursor Point) { if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) { diff --git a/ecs/component.go b/ecs/component.go new file mode 100644 index 0000000..29ef967 --- /dev/null +++ b/ecs/component.go @@ -0,0 +1,10 @@ +package ecs + +type Component[T any] struct { + IsEnabled bool + Value T +} + +func C[T any](t T) Component[T] { + return Component[T]{IsEnabled: true, Value: t} +} diff --git a/entity b/entity new file mode 100755 index 0000000..eae6e8d Binary files /dev/null and b/entity differ diff --git a/entity.go b/entity.go index 838bd71..8d6dfb9 100644 --- a/entity.go +++ b/entity.go @@ -1,31 +1,27 @@ package main -import "github.com/hajimehoshi/ebiten/v2" +import ( + "age_of_empires/ecs" + "age_of_empires/physics" -type Component[T any] struct { - IsEnabled bool - Value T -} - -func C[T any](t T) Component[T] { - return Component[T]{IsEnabled: true, Value: t} -} + "github.com/hajimehoshi/ebiten/v2" +) type Entity struct { - Position Component[Point] - Image Component[*ebiten.Image] - Selection Component[Selection] - Move Component[Move] - Order Component[Order] - ResourceGatherer Component[ResourceGatherer] - ResourceSource Component[ResourceSource] - ResourceStorage Component[ResourceStorage] + Position ecs.Component[physics.Point] + Image ecs.Component[*ebiten.Image] + Selection ecs.Component[Selection] + Move ecs.Component[physics.Move] + Order ecs.Component[Order] + ResourceGatherer ecs.Component[ResourceGatherer] + ResourceSource ecs.Component[ResourceSource] + ResourceStorage ecs.Component[ResourceStorage] } -func (e Entity) Bounds() Rectangle { - return Rectangle{ - e.Position.Value, - e.Position.Value.Add(e.Image.Value.Bounds().Size()), +func (e Entity) Bounds() physics.Rectangle { + return physics.Rectangle{ + Min: e.Position.Value, + Max: e.Position.Value.Add(e.Image.Value.Bounds().Size()), } } diff --git a/gather_order.go b/gather_order.go index b4f3c9e..2f5a060 100644 --- a/gather_order.go +++ b/gather_order.go @@ -1,6 +1,7 @@ package main import ( + "age_of_empires/physics" "math" "time" @@ -22,7 +23,7 @@ func (o *GatherOrder) Update(e *Entity, g *Game) { o.updateGathererFull(e, g, gatherer) return } - if Distance(e.Position.Value, gatherer.CurrentTarget.Position.Value) <= 100 { + if physics.Distance(e.Position.Value, gatherer.CurrentTarget.Position.Value) <= 100 { o.updateGathering(gatherer, source) return } @@ -56,7 +57,7 @@ func (o *GatherOrder) updateGathererFull(e *Entity, g *Game, gatherer *ResourceG 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())) - e.StartMove(destination, g.getMoveMap()) + physics.StartMove(&e.Move,e.Position, destination, g.getMoveMap()) return } gatherer.CurrentVolume = 0 @@ -65,21 +66,21 @@ func (o *GatherOrder) updateGathererFull(e *Entity, g *Game, gatherer *ResourceG } func (o *GatherOrder) startMoveToSource(e *Entity, gatherer *ResourceGatherer, g *Game) { - sourceDockings := AdjacentPoints(gatherer.CurrentTarget.Position.Value) + sourceDockings := physics.AdjacentPoints(gatherer.CurrentTarget.Position.Value) destination, distance := g.Closest(e.Position.Value, sourceDockings) if distance == math.MaxInt { slog.Info("no accessible storage, canceling gather order") e.Order.Value = nil return } - e.StartMove(destination, g.getMoveMap()) + physics.StartMove(&e.Move,e.Position, destination, g.getMoveMap()) } -func getAllStorageDockings(g *Game) []Point { - storageDockings := []Point{} +func getAllStorageDockings(g *Game) []physics.Point { + storageDockings := []physics.Point{} for _, entity := range g.Entities { if entity.Position.IsEnabled && entity.ResourceStorage.IsEnabled { - storageDockings = append(storageDockings, AdjacentPoints(entity.Position.Value)...) + storageDockings = append(storageDockings, physics.AdjacentPoints(entity.Position.Value)...) } } return storageDockings @@ -91,14 +92,14 @@ func Gather(e *Entity, source *Entity, g *Game) Order { slog.Info("gathering from", slog.String("source", source.Position.Value.String())) e.Order.Value = &GatherOrder{source: source} e.ResourceGatherer.Value.CurrentTarget = source - sourceDockings := AdjacentPoints(source.Position.Value) + sourceDockings := physics.AdjacentPoints(source.Position.Value) destination, distance := g.Closest(e.Position.Value, sourceDockings) if distance == math.MaxInt { slog.Info("no accessible storage, canceling gather order") e.Order.Value = nil return nil } - e.StartMove(destination, g.getMoveMap()) + physics.StartMove(&e.Move,e.Position, destination, g.getMoveMap()) } } return &GatherOrder{source: source} diff --git a/geometry.go b/geometry.go deleted file mode 100644 index ba3f643..0000000 --- a/geometry.go +++ /dev/null @@ -1,30 +0,0 @@ -package main - -import ( - "image" - "math" -) - -type Point = image.Point -type Rectangle = image.Rectangle - -func Distance(p1, p2 Point) float64 { - return math.Sqrt(math.Pow(float64(p1.X-p2.X), 2) + math.Pow(float64(p1.Y-p2.Y), 2)) -} - -func Length(p Point) float64 { - return Distance(p, Point{0, 0}) -} - -func Normalize(p Point) Point { - return p.Div(int(Length(p))) -} - -func AdjacentPoints(from Point) []Point { - return []Point{ - from.Add(Point{X: 0, Y: -100}), - from.Add(Point{X: 0, Y: 100}), - from.Add(Point{X: -100, Y: 0}), - from.Add(Point{X: 100, Y: 0}), - } -} diff --git a/image.go b/image.go index 5045d90..50f1a19 100644 --- a/image.go +++ b/image.go @@ -1,19 +1,20 @@ package main import ( + "age_of_empires/physics" "image/color" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/vector" ) -func NewFilledRectangleImage(size Point, color color.Color) *ebiten.Image { +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 Point, strokeWidth int, color color.Color) *ebiten.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 diff --git a/main.go b/main.go index 8ed3133..5eb330e 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "age_of_empires/ecs" + "age_of_empires/physics" "image" "image/color" _ "image/jpeg" @@ -24,7 +26,7 @@ var soilColor = color.RGBA{0x60, 0x40, 0x20, 0xff} type GlobalSelection struct { IsActive bool - Start Point + Start physics.Point } type Game struct { @@ -33,22 +35,36 @@ type Game struct { Selection GlobalSelection } -func (g *Game) getMoveMap() MoveMap { - blocked := map[Point]bool{} +func DrawMove(screen *ebiten.Image, e *Entity) { + if e.Move.IsEnabled && e.Position.IsEnabled && e.Selection.IsEnabled { + if e.Selection.Value.IsSelected && e.Move.Value.IsActive { + last := e.Position.Value + dx := +e.Image.Value.Bounds().Dx() / 2 + dy := +e.Image.Value.Bounds().Dy() / 2 + for _, point := range e.Move.Value.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) + last = point + } + } + } +} + +func (g *Game) getMoveMap() physics.MoveMap { + blocked := map[physics.Point]bool{} for _, e := range g.Entities { if e.Position.IsEnabled { blocked[e.Position.Value] = true } } - return MoveMap{Width: 3200, Height: 2400, Blocked: blocked} + return physics.MoveMap{Width: 3200, Height: 2400, Blocked: blocked} } -func (g *Game) Closest(from Point, candidates []Point) (Point, int) { - var closest Point +func (g *Game) Closest(from physics.Point, candidates []physics.Point) (physics.Point, int) { + var closest physics.Point closestDistance := math.MaxInt moveMap := g.getMoveMap() for _, dest := range candidates { - path, ok := SearchPath(from, dest, moveMap) + path, ok := physics.SearchPath(from, dest, moveMap) if !ok { continue } @@ -60,7 +76,7 @@ func (g *Game) Closest(from Point, candidates []Point) (Point, int) { return closest, closestDistance } -func (g *Game) updateSelecting(cursor Point, moveMap MoveMap) { +func (g *Game) updateSelecting(cursor physics.Point, moveMap physics.MoveMap) { if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { g.Selection.Start = cursor g.Selection.IsActive = true @@ -80,13 +96,13 @@ func (g *Game) updateSelecting(cursor Point, moveMap MoveMap) { if entityAtDestination != nil && entityAtDestination.ResourceSource.IsEnabled { Gather(e, entityAtDestination, g) } else { - e.StartMove(destination, moveMap) + physics.StartMove(&e.Move,e.Position, destination, moveMap) } } } } if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) { - if g.Selection.IsActive && Distance(g.Selection.Start, cursor) > 10 { + if g.Selection.IsActive && physics.Distance(g.Selection.Start, cursor) > 10 { for _, e := range g.Entities { e.SelectMultiple(cursor, g.Selection) } @@ -102,7 +118,7 @@ func (g *Game) updateSelecting(cursor Point, moveMap MoveMap) { } } -func (g *Game) updatePatrolling(cursor Point) { +func (g *Game) updatePatrolling(cursor physics.Point) { if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight) { destination := cursor.Div(100).Mul(100) for _, e := range g.Entities { @@ -114,7 +130,7 @@ func (g *Game) updatePatrolling(cursor Point) { func (g *Game) Update() error { x, y := ebiten.CursorPosition() - cursor := Point{x, y} + cursor := physics.Point{X: x, Y: y} if inpututil.IsKeyJustReleased(ebiten.KeyEscape) { g.CurrentAction = Selecting slog.Info("selecting action") @@ -131,7 +147,7 @@ func (g *Game) Update() error { g.updatePatrolling(cursor) } for _, e := range g.Entities { - e.UpdateMove(moveMap) + physics.UpdateMove(&e.Move,&e.Position, moveMap) e.UpdateOrder(g) } return nil @@ -139,7 +155,7 @@ func (g *Game) Update() error { func (g *Game) Draw(screen *ebiten.Image) { x, y := ebiten.CursorPosition() - cursor := Point{x, y} + cursor := physics.Point{X: x, Y: y} screen.Fill(soilColor) for _, e := range g.Entities { Draw(screen, e) @@ -175,39 +191,39 @@ func main() { } ebiten.SetWindowIcon([]image.Image{iconImg}) game := &Game{} - ironImage := NewFilledRectangleImage(Point{100, 100}, color.RGBA{0x80, 0x80, 0x80, 0xff}) + ironImage := NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x80, 0x80, 0x80, 0xff}) ironMine := Entity{ - Position: C(Point{1000, 1000}), - Image: C(ironImage), - ResourceSource: C(ResourceSource{Remaining: 1000}), + Position: ecs.C(physics.Point{X: 1000, Y: 1000}), + Image: ecs.C(ironImage), + ResourceSource: ecs.C(ResourceSource{Remaining: 1000}), } game.Entities = append(game.Entities, &ironMine) - storageImage := NewFilledRectangleImage(Point{100, 100}, color.RGBA{0x00, 0x00, 0xff, 0xff}) + storageImage := NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x00, 0x00, 0xff, 0xff}) storage := Entity{ - Position: C(Point{1000, 2000}), - Image: C(storageImage), - ResourceStorage: C(ResourceStorage{}), + Position: ecs.C(physics.Point{X: 1000, Y: 2000}), + Image: ecs.C(storageImage), + ResourceStorage: ecs.C(ResourceStorage{}), } game.Entities = append(game.Entities, &storage) var order Order personImage := NewFilledCircleImage(100, color.RGBA{0xff, 0xff, 0xff, 0xff}) personSelectionHalo := NewStrokeCircleImage(110, SELECTION_HALO_WIDTH, color.RGBA{0xff, 0x00, 0x00, 0xff}) person1 := Entity{ - Position: C(Point{2000, 2000}), - Image: C(personImage), - Selection: C(Selection{IsSelected: false, Halo: personSelectionHalo}), - Move: C(Move{IsActive: false}), - Order: C(order), - ResourceGatherer: C(ResourceGatherer{MaxCapacity: 15}), + Position: ecs.C(physics.Point{X: 2000, Y: 2000}), + Image: ecs.C(personImage), + Selection: ecs.C(Selection{IsSelected: false, Halo: personSelectionHalo}), + Move: ecs.C(physics.Move{IsActive: false}), + Order: ecs.C(order), + ResourceGatherer: ecs.C(ResourceGatherer{MaxCapacity: 15}), } game.Entities = append(game.Entities, &person1) person2 := Entity{ - Position: C(Point{2200, 2200}), - Image: C(personImage), - Selection: C(Selection{IsSelected: false, Halo: personSelectionHalo}), - Move: C(Move{IsActive: false}), - Order: C(order), - ResourceGatherer: C(ResourceGatherer{MaxCapacity: 15}), + Position: ecs.C(physics.Point{X: 2200, Y: 2200}), + Image: ecs.C(personImage), + Selection: ecs.C(Selection{IsSelected: false, Halo: personSelectionHalo}), + Move: ecs.C(physics.Move{IsActive: false}), + Order: ecs.C(order), + ResourceGatherer: ecs.C(ResourceGatherer{MaxCapacity: 15}), } game.Entities = append(game.Entities, &person2) game.CurrentAction = Selecting diff --git a/move.go b/move.go deleted file mode 100644 index 1322a51..0000000 --- a/move.go +++ /dev/null @@ -1,220 +0,0 @@ -package main - -import ( - "fmt" - "image/color" - "math" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/vector" - "golang.org/x/exp/slices" - "golang.org/x/exp/slog" -) - -const MOVE_SPEED = 10.0 - -type Path []Point - -func (p Path) String() string { - return fmt.Sprintf("%s -> %s", p[0].String(), p[len(p)-1].String()) -} - -type Move struct { - IsActive bool - Origin Point - Destination Point - Path Path -} - -type Node struct { - CostFromOrigin int - CostToDestination int - Parent Point -} - -func (n Node) Cost() int { - return n.CostFromOrigin + n.CostToDestination -} - -type MoveMap struct { - Width int - Height int - Blocked map[Point]bool -} - -type PathSearch struct { - origin Point - destination Point - openList map[Point]Node - closedList map[Point]Node - moveMap MoveMap -} - -func SearchPath(origin Point, destination Point, moveMap MoveMap) (Path, bool) { - search := PathSearch{ - origin: origin, - destination: destination, - openList: map[Point]Node{}, - closedList: map[Point]Node{}, - moveMap: moveMap, - } - return search.search() -} - -func (s *PathSearch) addNeighbors(point Point, node Node) { - if point.X >= 100 { - s.consider(Point{point.X - 100, point.Y}, point) - } - if point.X <= s.moveMap.Width-100 { - s.consider(Point{point.X + 100, point.Y}, point) - } - if point.Y >= 100 { - s.consider(Point{point.X, point.Y - 100}, point) - } - if point.Y <= s.moveMap.Height-100 { - s.consider(Point{point.X, point.Y + 100}, point) - } -} - -func (s *PathSearch) consider(neighbor Point, point Point) { - if s.moveMap.Blocked[neighbor] { - return - } - if _, ok := s.closedList[neighbor]; !ok { - node := Node{ - CostFromOrigin: s.closedList[point].CostFromOrigin + int(Distance(point, neighbor)), - CostToDestination: int(Distance(neighbor, s.destination)), - Parent: point, - } - existing, ok := s.openList[neighbor] - if !ok || existing.Cost() > node.Cost() { - s.openList[neighbor] = node - } - } -} - -// openList must not be empty -func (s *PathSearch) bestOpenNode() (Point, Node) { - bestPoint := Point{} - bestNode := Node{} - bestCost := math.MaxInt32 - for point, node := range s.openList { - if cost := node.Cost(); cost < bestCost { - bestPoint = point - bestNode = node - bestCost = cost - } - } - return bestPoint, bestNode -} - -func (s *PathSearch) buildPath() Path { - point := s.destination - path := Path{s.destination} - node := s.closedList[s.destination] - for point != s.origin { - point = node.Parent - path = append(path, point) - node = s.closedList[point] - } - slices.Reverse(path) - return path -} - -func (s *PathSearch) search() (Path, bool) { - s.openList[s.origin] = Node{CostFromOrigin: 0, CostToDestination: int(Distance(s.origin, s.destination)), Parent: s.origin} - point := s.origin - var node Node - for point != s.destination && len(s.openList) > 0 { - point, node = s.bestOpenNode() - s.closedList[point] = node - delete(s.openList, point) - s.addNeighbors(point, node) - } - if point == s.destination { - return s.buildPath(), true - } - return Path{}, false -} - -func NewMove(origin Point, destination Point, moveMap MoveMap) Move { - current := Point{ - origin.X / 100 * 100, - origin.Y / 100 * 100, - } - path, ok := SearchPath(current, destination, moveMap) - if !ok { - slog.Info("no path found", slog.String("destination", destination.String())) - return Move{IsActive: false} - } - return Move{IsActive: true, Origin: origin, Destination: destination, Path: path[1:]} -} - -func (p *Path) isValid(moveMap MoveMap) bool { - for _, point := range *p { - if moveMap.Blocked[point] { - return false - } - } - return true -} - -func (e *Entity) StartMove(destination Point, moveMap MoveMap) { - if e.Move.IsEnabled && e.Position.IsEnabled && e.Selection.IsEnabled { - e.Move.Value = NewMove(e.Position.Value, destination, moveMap) - slog.Info("entity starting move", slog.String("destination", destination.String())) - } -} - -func (e *Entity) moveToward(destination Point) { - remainingMove := destination.Sub(e.Position.Value) - delta := remainingMove.Mul(MOVE_SPEED).Div(int(Length(remainingMove))) - e.Position.Value = e.Position.Value.Add(delta) -} - -func (e *Entity) UpdateMove(moveMap MoveMap) { - if !e.Move.IsEnabled || !e.Position.IsEnabled || !e.Move.Value.IsActive { - return - } - position := e.Position.Value - if len(e.Move.Value.Path) == 0 { - e.Move.Value.IsActive = false - return - } - next := e.Move.Value.Path[0] - if Distance(position, next) < MOVE_SPEED { - e.Position.Value = next - remainingPath := e.Move.Value.Path[1:] - if next == e.Move.Value.Destination { - e.Move.Value.IsActive = false - slog.Info("move finished", slog.String("destination", e.Move.Value.Destination.String())) - } else if remainingPath.isValid(moveMap) { - e.Move.Value.Path = remainingPath - } else { - // Here maybe the destination has to change - path, ok := SearchPath(next, e.Move.Value.Destination, moveMap) - if !ok { - slog.Info("no path found", slog.String("destination", e.Move.Value.Destination.String())) - e.Move.Value.IsActive = false - } else { - e.Move.Value.Path = path[1:] - } - } - } else { - e.moveToward(next) - } -} - -func DrawMove(screen *ebiten.Image, e *Entity) { - if e.Move.IsEnabled && e.Position.IsEnabled && e.Selection.IsEnabled { - if e.Selection.Value.IsSelected && e.Move.Value.IsActive { - last := e.Position.Value - dx := +e.Image.Value.Bounds().Dx() / 2 - dy := +e.Image.Value.Bounds().Dy() / 2 - for _, point := range e.Move.Value.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) - last = point - } - } - } -} diff --git a/patrol_order.go b/patrol_order.go index b7201ed..eb9dfb0 100644 --- a/patrol_order.go +++ b/patrol_order.go @@ -1,10 +1,14 @@ package main -import "golang.org/x/exp/slog" +import ( + "age_of_empires/physics" + + "golang.org/x/exp/slog" +) type PatrolOrder struct { - origin Point - destination Point + origin physics.Point + destination physics.Point } func (o *PatrolOrder) Update(e *Entity, g *Game) { @@ -12,15 +16,15 @@ func (o *PatrolOrder) Update(e *Entity, g *Game) { if !e.Move.Value.IsActive { moveMap := g.getMoveMap() if e.Position.Value == o.destination { - e.StartMove(o.origin, moveMap) + physics.StartMove(&e.Move, e.Position, o.origin, moveMap) } else { - e.StartMove(o.destination, moveMap) + physics.StartMove(&e.Move, e.Position, o.destination, moveMap) } } } } -func Patrol(e *Entity, destination Point) Order { +func Patrol(e *Entity, destination physics.Point) Order { if e.Selection.IsEnabled && e.Position.IsEnabled && e.Move.IsEnabled && e.Order.IsEnabled { if e.Selection.Value.IsSelected { origin := e.Position.Value diff --git a/physics/geometry.go b/physics/geometry.go new file mode 100644 index 0000000..1902fdf --- /dev/null +++ b/physics/geometry.go @@ -0,0 +1,30 @@ +package physics + +import ( + "image" + "math" +) + +type Point = image.Point +type Rectangle = image.Rectangle + +func Distance(p1, p2 Point) float64 { + return math.Sqrt(math.Pow(float64(p1.X-p2.X), 2) + math.Pow(float64(p1.Y-p2.Y), 2)) +} + +func Length(p Point) float64 { + return Distance(p, Point{X:0,Y:0}) +} + +func Normalize(p Point) Point { + return p.Div(int(Length(p))) +} + +func AdjacentPoints(from Point) []Point { + return []Point{ + from.Add(Point{X: 0, Y: -100}), + from.Add(Point{X: 0, Y: 100}), + from.Add(Point{X: -100, Y: 0}), + from.Add(Point{X: 100, Y: 0}), + } +} diff --git a/physics/move.go b/physics/move.go new file mode 100644 index 0000000..b6f13d7 --- /dev/null +++ b/physics/move.go @@ -0,0 +1,203 @@ +package physics + +import ( + "age_of_empires/ecs" + "fmt" + "math" + + "golang.org/x/exp/slices" + "golang.org/x/exp/slog" +) + +const MOVE_SPEED = 10.0 + +type Path []Point + +func (p Path) String() string { + return fmt.Sprintf("%s -> %s", p[0].String(), p[len(p)-1].String()) +} + +type Move struct { + IsActive bool + Origin Point + Destination Point + Path Path +} + +type Node struct { + CostFromOrigin int + CostToDestination int + Parent Point +} + +func (n Node) Cost() int { + return n.CostFromOrigin + n.CostToDestination +} + +type MoveMap struct { + Width int + Height int + Blocked map[Point]bool +} + +type PathSearch struct { + origin Point + destination Point + openList map[Point]Node + closedList map[Point]Node + moveMap MoveMap +} + +func SearchPath(origin Point, destination Point, moveMap MoveMap) (Path, bool) { + search := PathSearch{ + origin: origin, + destination: destination, + openList: map[Point]Node{}, + closedList: map[Point]Node{}, + moveMap: moveMap, + } + return search.search() +} + +func (s *PathSearch) addNeighbors(point Point, node Node) { + if point.X >= 100 { + s.consider(Point{X: point.X - 100, Y: point.Y}, point) + } + if point.X <= s.moveMap.Width-100 { + s.consider(Point{X: point.X + 100, Y: point.Y}, point) + } + if point.Y >= 100 { + s.consider(Point{X: point.X, Y: point.Y - 100}, point) + } + if point.Y <= s.moveMap.Height-100 { + s.consider(Point{X: point.X, Y: point.Y + 100}, point) + } +} + +func (s *PathSearch) consider(neighbor Point, point Point) { + if s.moveMap.Blocked[neighbor] { + return + } + if _, ok := s.closedList[neighbor]; !ok { + node := Node{ + CostFromOrigin: s.closedList[point].CostFromOrigin + int(Distance(point, neighbor)), + CostToDestination: int(Distance(neighbor, s.destination)), + Parent: point, + } + existing, ok := s.openList[neighbor] + if !ok || existing.Cost() > node.Cost() { + s.openList[neighbor] = node + } + } +} + +// openList must not be empty +func (s *PathSearch) bestOpenNode() (Point, Node) { + bestPoint := Point{X: 0, Y: 0} + bestNode := Node{} + bestCost := math.MaxInt32 + for point, node := range s.openList { + if cost := node.Cost(); cost < bestCost { + bestPoint = point + bestNode = node + bestCost = cost + } + } + return bestPoint, bestNode +} + +func (s *PathSearch) buildPath() Path { + point := s.destination + path := Path{s.destination} + node := s.closedList[s.destination] + for point != s.origin { + point = node.Parent + path = append(path, point) + node = s.closedList[point] + } + slices.Reverse(path) + return path +} + +func (s *PathSearch) search() (Path, bool) { + s.openList[s.origin] = Node{CostFromOrigin: 0, CostToDestination: int(Distance(s.origin, s.destination)), Parent: s.origin} + point := s.origin + var node Node + for point != s.destination && len(s.openList) > 0 { + point, node = s.bestOpenNode() + s.closedList[point] = node + delete(s.openList, point) + s.addNeighbors(point, node) + } + if point == s.destination { + return s.buildPath(), true + } + return Path{}, false +} + +func NewMove(origin Point, destination Point, moveMap MoveMap) Move { + current := Point{ + X: origin.X / 100 * 100, + Y: origin.Y / 100 * 100, + } + path, ok := SearchPath(current, destination, moveMap) + if !ok { + slog.Info("no path found", slog.String("destination", destination.String())) + return Move{IsActive: false} + } + return Move{IsActive: true, Origin: origin, Destination: destination, Path: path[1:]} +} + +func (p *Path) isValid(moveMap MoveMap) bool { + for _, point := range *p { + if moveMap.Blocked[point] { + return false + } + } + return true +} + +func StartMove(move *ecs.Component[Move], position ecs.Component[Point], destination Point, moveMap MoveMap) { + if move.IsEnabled && position.IsEnabled { + move.Value = NewMove(position.Value, destination, moveMap) + slog.Info("entity starting move", slog.String("destination", destination.String())) + } +} + +func moveToward(position *ecs.Component[Point], destination Point) { + remainingMove := destination.Sub(position.Value) + delta := remainingMove.Mul(MOVE_SPEED).Div(int(Length(remainingMove))) + position.Value = position.Value.Add(delta) +} + +func UpdateMove(move *ecs.Component[Move], position *ecs.Component[Point], moveMap MoveMap) { + if !move.IsEnabled || !position.IsEnabled || !move.Value.IsActive { + return + } + if len(move.Value.Path) == 0 { + move.Value.IsActive = false + return + } + next := move.Value.Path[0] + if Distance(position.Value, next) < MOVE_SPEED { + position.Value = next + remainingPath := move.Value.Path[1:] + if next == move.Value.Destination { + move.Value.IsActive = false + slog.Info("move finished", slog.String("destination", move.Value.Destination.String())) + } else if remainingPath.isValid(moveMap) { + move.Value.Path = remainingPath + } else { + // Here maybe the destination has to change + path, ok := SearchPath(next, move.Value.Destination, moveMap) + if !ok { + slog.Info("no path found", slog.String("destination", move.Value.Destination.String())) + move.Value.IsActive = false + } else { + move.Value.Path = path[1:] + } + } + } else { + moveToward(position, next) + } +} diff --git a/selection.go b/selection.go index e41fce3..91f35fe 100644 --- a/selection.go +++ b/selection.go @@ -1,6 +1,8 @@ package main import ( + "age_of_empires/physics" + "github.com/hajimehoshi/ebiten/v2" "golang.org/x/exp/slog" ) @@ -12,10 +14,10 @@ type Selection struct { Halo *ebiten.Image } -func (e *Entity) SelectMultiple(cursor Point, selection GlobalSelection) { +func (e *Entity) SelectMultiple(cursor physics.Point, selection GlobalSelection) { if e.Selection.IsEnabled { e.Selection.Value.IsSelected = false - selectionBounds := Rectangle{selection.Start, cursor}.Canon() + selectionBounds := physics.Rectangle{Min:selection.Start, Max:cursor}.Canon() if selectionBounds.Overlaps(e.Bounds()) { e.Selection.Value.IsSelected = true slog.Info("entity selected", slog.String("position", e.Position.Value.String())) @@ -23,7 +25,7 @@ func (e *Entity) SelectMultiple(cursor Point, selection GlobalSelection) { } } -func (e *Entity) SelectSingle(cursor Point, canBeSelected bool) bool { +func (e *Entity) SelectSingle(cursor physics.Point, canBeSelected bool) bool { if e.Selection.IsEnabled { e.Selection.Value.IsSelected = false if canBeSelected && cursor.In(e.Bounds()) { -- cgit v1.3