From 13626410c4d6db7fdc15ded5ee7865fcdbf0b88a Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 15 Jan 2026 22:05:38 +0100 Subject: refactor: transition to struct-of-arrays ECS architecture - Replace individual Entity structs with centralized component slices in Game struct (SoA layout). - Implement bitmask-based component lookup (CM_Position, CM_Move, etc.). - Convert logic from object-oriented Update() methods to standalone Systems (UpdateMoveSystem, UpdateGatherSystem, UpdateSpawnSystem). - Update rendering, selection, and input handling to iterate over component arrays. --- README.md | 5 ++ draw.go | 132 +++++++++++++++++++++--------------------- ecs/component.go | 22 ++++--- ecs/entity.go | 3 + entity.go | 70 ++++++++++++---------- gather_order.go | 95 ++++++++++++------------------ main.go | 153 ++++++++++++++++++++++++++++--------------------- monad/monad.go | 10 ++++ order.go | 44 +++++++------- patrol_order.go | 39 ++++++++----- physics/move.go | 86 +++++++++++++-------------- resource.go | 4 +- selection/selection.go | 87 +++++++++++++++++----------- spawn.go | 60 +++++++++---------- 14 files changed, 440 insertions(+), 370 deletions(-) create mode 100644 ecs/entity.go create mode 100644 monad/monad.go diff --git a/README.md b/README.md index 7400fb5..e3ba5a5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ - perf: look for nil comparison -> more data oriented - perf: keep a short array of selected and use it instead of `Value.IsSelected` - perf: component columns and systems +- ecs: bitfield for fast checking +- ecs: rename to systems +- perf: measure frame budget +- rename Selection to Selectable +- extract todos in todo list ## Bugs diff --git a/draw.go b/draw.go index 970974f..4f97cb8 100644 --- a/draw.go +++ b/draw.go @@ -4,6 +4,7 @@ import ( "fmt" "image/color" + "age_of_empires/ecs" "age_of_empires/physics" "age_of_empires/selection" @@ -12,51 +13,74 @@ import ( "github.com/hajimehoshi/ebiten/v2/vector" ) -func (e *Entity) Draw(g *Game, screen *ebiten.Image) { - if !e.Image.IsEnabled || !e.Position.IsEnabled { - return - } - op := &ebiten.DrawImageOptions{} - op.GeoM.Translate(float64(e.Position.Value.X), float64(e.Position.Value.Y)) - screen.DrawImage(e.Image.Value, op) - if e.Selection.IsEnabled && e.Selection.Value.IsSelected { - opt := &ebiten.DrawImageOptions{} - opt.GeoM.Translate(float64(e.Position.Value.X-selection.HaloWidth/2), float64(e.Position.Value.Y-selection.HaloWidth/2)) - screen.DrawImage(e.Selection.Value.Halo, opt) - if e.ResourceSource.IsEnabled { - resourceText := fmt.Sprintf("%d", e.ResourceSource.Value.Remaining) - op := &text.DrawOptions{} - op.GeoM.Translate(float64(e.Position.Value.X+5), float64(e.Position.Value.Y+30)) - op.ColorScale.ScaleWithColor(color.White) - text.Draw(screen, resourceText, &text.GoTextFace{ - Source: g.FaceSource, - Size: 40, - }, op) - } - if e.Spawn.IsEnabled { - spawnText := fmt.Sprintf("%d", len(e.Spawn.Value.Requests)) - op := &text.DrawOptions{} - op.GeoM.Translate(float64(e.Position.Value.X+5), float64(e.Position.Value.Y+70)) - op.ColorScale.ScaleWithColor(color.White) - text.Draw(screen, spawnText, &text.GoTextFace{ - Source: g.FaceSource, - Size: 40, - }, op) - if e.Spawn.Value.SpawnTarget.IsEnabled { - spawnTarget := e.Spawn.Value.SpawnTarget.Value - vector.DrawFilledCircle(screen, float32(spawnTarget.X+50), float32(spawnTarget.Y+50), 30, Red, true) - } - } - if 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) Draw(screen *ebiten.Image) { + x, y := ebiten.CursorPosition() + cursor := physics.Point{X: x, Y: y} + screen.Fill(soilColor) + + 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)) + screen.DrawImage(image, op) + if mask&ecs.CM_Selection == ecs.CM_Selection { + selectable := g.Selection[i] + if selectable.IsSelected { + opt := &ebiten.DrawImageOptions{} + opt.GeoM.Translate(float64(position.X-selection.HaloWidth/2), float64(position.Y-selection.HaloWidth/2)) + 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.ColorScale.ScaleWithColor(color.White) + text.Draw(screen, resourceText, &text.GoTextFace{ + Source: g.FaceSource, + Size: 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.ColorScale.ScaleWithColor(color.White) + text.Draw(screen, spawnText, &text.GoTextFace{ + Source: g.FaceSource, + Size: 40, + }, op) + if spawn.SpawnTarget.Ok { + spawnTarget := spawn.SpawnTarget.Value + vector.DrawFilledCircle(screen, float32(spawnTarget.X+50), float32(spawnTarget.Y+50), 30, 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 + for _, point := range move.Path { + vector.StrokeLine(screen, float32(last.X+dx), float32(last.Y+dy), float32(point.X+dx), float32(point.Y+dy), 10.0, color.RGBA{256 * 3 / 16, 256 * 3 / 16, 256 * 3 / 16, 256 / 4}, true) + last = point + } + } + } } } } + if g.GlobalSelection.IsActive(cursor) { + g.GlobalSelection.Draw(screen, cursor) + } + + drawTopBanner(screen, g) + + if len(g.GlobalSelection.Selected) > 0 { + drawBottomBanner(screen, g) + } } func drawTopBanner(screen *ebiten.Image, g *Game) { @@ -87,27 +111,3 @@ func drawBottomBanner(screen *ebiten.Image, g *Game) { Size: 100, }, op) } - -func (g *Game) Draw(screen *ebiten.Image) { - x, y := ebiten.CursorPosition() - cursor := physics.Point{X: x, Y: y} - screen.Fill(soilColor) - for _, e := range g.Entities { - e.Draw(g, screen) - } - if g.Selection.IsActive(cursor) { - g.Selection.Draw(screen, cursor) - } - - drawTopBanner(screen, g) - - selected := make([]*Entity, 0, len(g.Entities)) - for _, e := range g.Entities { - if e.Selection.IsEnabled && e.Selection.Value.IsSelected { - selected = append(selected, e) - } - } - if len(selected) > 0 { - drawBottomBanner(screen, g) - } -} diff --git a/ecs/component.go b/ecs/component.go index 29ef967..ff22e53 100644 --- a/ecs/component.go +++ b/ecs/component.go @@ -1,10 +1,18 @@ package ecs -type Component[T any] struct { - IsEnabled bool - Value T -} +type Mask uint16 -func C[T any](t T) Component[T] { - return Component[T]{IsEnabled: true, Value: t} -} +const ( + CM_Position Mask = 1 << iota + CM_RelBounds + CM_Image + CM_Selection + CM_Move + CM_Order + CM_PatrolOrder + CM_GatherOrder + CM_ResourceGatherer + CM_ResourceSource + CM_ResourceStorage + CM_Spawn +) diff --git a/ecs/entity.go b/ecs/entity.go new file mode 100644 index 0000000..636470c --- /dev/null +++ b/ecs/entity.go @@ -0,0 +1,3 @@ +package ecs + +type Entity int diff --git a/entity.go b/entity.go index 35d2777..73aa118 100644 --- a/entity.go +++ b/entity.go @@ -1,95 +1,107 @@ package main import ( + "image/color" + "age_of_empires/ecs" "age_of_empires/physics" "age_of_empires/selection" - "image/color" "github.com/hajimehoshi/ebiten/v2" ) -type Entity struct { - Position ecs.Component[physics.Point] - RelBounds ecs.Component[physics.Rectangle] - Image ecs.Component[*ebiten.Image] - Selection ecs.Component[selection.Selection] - Move ecs.Component[physics.Move] - Order ecs.Component[Order] - ResourceGatherer ecs.Component[ResourceGatherer] - ResourceSource ecs.Component[ResourceSource] - ResourceStorage ecs.Component[ResourceStorage] - Spawn ecs.Component[Spawn] +type Components struct { + Position physics.Point + RelBounds physics.Rectangle + Image *ebiten.Image + Selection selection.Selection + Move physics.Move + Order OrderKind + ResourceGatherer ResourceGatherer + ResourceSource ResourceSource + ResourceStorage ResourceStorage + Spawn Spawn } type EntityBuilder struct { - entity Entity + mask ecs.Mask + components Components } func NewEntityBuilder() EntityBuilder { return EntityBuilder{ - entity: Entity{}, + components: Components{}, } } func (b EntityBuilder) WithPosition(position physics.Point) EntityBuilder { - b.entity.Position = ecs.C(position) + b.components.Position = position + b.mask |= ecs.CM_Position return b } func (b EntityBuilder) WithSolid(image *ebiten.Image) EntityBuilder { - b.entity.Image = ecs.C(image) - b.entity.RelBounds = ecs.C(image.Bounds()) + 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) WithSelection(haloKind string, priority selection.Priority) EntityBuilder { - b.entity.Selection = ecs.C(selection.Selection{ + b.components.Selection = selection.Selection{ IsSelected: false, Halo: nil, Priority: priority, - }) + } + b.mask |= ecs.CM_Selection switch haloKind { case "round": - b.entity.Selection.Value.Halo = NewStrokeCircleImage(110, selection.HaloWidth, Red) + b.components.Selection.Halo = NewStrokeCircleImage(110, selection.HaloWidth, Red) case "square": - b.entity.Selection.Value.Halo = NewStrokeRectangleImage(physics.Point{X: 110, Y: 110}, selection.HaloWidth, Red) + b.components.Selection.Halo = NewStrokeRectangleImage(physics.Point{X: 110, Y: 110}, selection.HaloWidth, Red) } return b } func (b EntityBuilder) WithResourceStorage() EntityBuilder { - b.entity.ResourceStorage = ecs.C(ResourceStorage{}) + b.components.ResourceStorage = ResourceStorage{} + b.mask |= ecs.CM_ResourceStorage return b } func (b EntityBuilder) WithResourceSource(amount int) EntityBuilder { - b.entity.ResourceSource = ecs.C(ResourceSource{Remaining: amount}) + b.components.ResourceSource = ResourceSource{Remaining: amount} + b.mask |= ecs.CM_ResourceSource return b } func (b EntityBuilder) WithSpawn(spawn Spawn) EntityBuilder { - b.entity.Spawn = ecs.C(spawn) + b.components.Spawn = spawn + b.mask |= ecs.CM_Spawn return b } func (b EntityBuilder) WithMove() EntityBuilder { - b.entity.Move = ecs.C(physics.Move{IsActive: false}) + b.components.Move = physics.Move{IsActive: false} + b.mask |= ecs.CM_Move return b } func (b EntityBuilder) WithOrder() EntityBuilder { - b.entity.Order.IsEnabled = true + b.components.Order = OrderKindNone + b.mask |= ecs.CM_Order return b } func (b EntityBuilder) WithResourceGatherer(maxCapacity int) EntityBuilder { - b.entity.ResourceGatherer = ecs.C(ResourceGatherer{MaxCapacity: maxCapacity}) + b.components.ResourceGatherer = ResourceGatherer{MaxCapacity: maxCapacity} + b.mask |= ecs.CM_ResourceGatherer return b } -func (b EntityBuilder) Build() Entity { - return b.entity +func (b EntityBuilder) Build() (Components, ecs.Mask) { + return b.components, b.mask } diff --git a/gather_order.go b/gather_order.go index eec9b99..fd56fbe 100644 --- a/gather_order.go +++ b/gather_order.go @@ -4,35 +4,34 @@ import ( "math" "time" + "age_of_empires/ecs" "age_of_empires/physics" "golang.org/x/exp/slog" ) -type GatherOrder struct { - source *Entity -} - -func (o *GatherOrder) Update(e *Entity, g *Game) { - if !e.Position.IsEnabled || !e.Move.IsEnabled || !e.ResourceGatherer.IsEnabled || e.ResourceGatherer.Value.CurrentTarget == nil { - return - } - - gatherer := &e.ResourceGatherer.Value - if gatherer.CurrentVolume == gatherer.MaxCapacity { - o.updateGathererFull(e, g, gatherer) - return - } - if physics.Distance(e.Position.Value, gatherer.CurrentTarget.Position.Value) <= 100 { - o.updateGathering(g.Now(), gatherer, &gatherer.CurrentTarget.ResourceSource.Value) - return - } - if !e.Move.Value.IsActive { - o.startMoveToSource(e, gatherer, g) +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 (*GatherOrder) updateGathering(now time.Time, gatherer *ResourceGatherer, source *ResourceSource) { +func updateGathering(now time.Time, gatherer *ResourceGatherer, source *ResourceSource) { if gatherer.CurrentVolume >= gatherer.MaxCapacity || gatherer.LastPickupTime.Add(200*time.Millisecond).After(now) { return } @@ -41,63 +40,41 @@ func (*GatherOrder) updateGathering(now time.Time, gatherer *ResourceGatherer, s source.Remaining -= 1 } -func (o *GatherOrder) updateGathererFull(e *Entity, g *Game, gatherer *ResourceGatherer) { - if e.Move.Value.IsActive { +func updateGathererFull(g *Game, storageDockings []physics.Point, moveMap physics.MoveMap, gatherer *ResourceGatherer, order *OrderKind, move *physics.Move, position physics.Point) { + if move.IsActive { return } - storageDockings := getAllStorageDockings(g) - destination, distance := physics.Closest(e.Position.Value, storageDockings, g.getMoveMap()) + destination, distance := physics.Closest(position, storageDockings, moveMap) if distance == math.MaxInt { slog.Info("no accessible storage, canceling gather order") - e.Order.Value = nil + *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())) - physics.StartMove(&e.Move, e.Position, destination, g.getMoveMap()) + *move = physics.NewMove(position, destination, moveMap) return } g.ResourceAmount += gatherer.CurrentVolume gatherer.CurrentVolume = 0 - o.startMoveToSource(e, gatherer, g) + startMoveToSource(moveMap, gatherer, move, order, position, g.Position[gatherer.Source]) } -func (o *GatherOrder) startMoveToSource(e *Entity, gatherer *ResourceGatherer, g *Game) { - sourceDockings := physics.AdjacentPoints(gatherer.CurrentTarget.Position.Value) - destination, distance := physics.Closest(e.Position.Value, sourceDockings, g.getMoveMap()) +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") - e.Order.Value = nil + *order = OrderKindNone return } - physics.StartMove(&e.Move, e.Position, destination, g.getMoveMap()) -} - -func getAllStorageDockings(g *Game) []physics.Point { - storageDockings := []physics.Point{} - for _, entity := range g.Entities { - if entity.Position.IsEnabled && entity.ResourceStorage.IsEnabled { - storageDockings = append(storageDockings, physics.AdjacentPoints(entity.Position.Value)...) - } - } - return storageDockings + *move = physics.NewMove(position, destination, moveMap) } -func Gather(e *Entity, source *Entity, g *Game) Order { - if !e.Position.IsEnabled || !e.Move.IsEnabled || !e.Order.IsEnabled { - return nil - } - slog.Info("gathering from", slog.String("source", source.Position.Value.String())) - e.Order.Value = &GatherOrder{source: source} - e.ResourceGatherer.Value.CurrentTarget = source - sourceDockings := physics.AdjacentPoints(source.Position.Value) - destination, distance := physics.Closest(e.Position.Value, sourceDockings, g.getMoveMap()) - if distance == math.MaxInt { - slog.Info("no accessible storage, canceling gather order") - e.Order.Value = nil - return nil - } - physics.StartMove(&e.Move, e.Position, destination, g.getMoveMap()) - return &GatherOrder{source: source} +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/main.go b/main.go index 6392488..e67e50b 100644 --- a/main.go +++ b/main.go @@ -31,37 +31,79 @@ const ( var soilColor = color.RGBA{0x60, 0x40, 0x20, 0xff} type Game struct { - Entities []*Entity - CurrentAction Action - Selection selection.GlobalSelection - ResourceAmount int - FaceSource *text.GoTextFaceSource - UnitBuilder EntityBuilder + CurrentAction Action + GlobalSelection selection.GlobalSelection + ResourceAmount int + FaceSource *text.GoTextFaceSource + UnitBuilder EntityBuilder + // Components + Mask []ecs.Mask + // Entities []*Entity + Position []physics.Point + RelBounds []physics.Rectangle + Image []*ebiten.Image + Selection []selection.Selection + Move []physics.Move + Order []OrderKind + PatrolOrder []PatrolOrder + ResourceGatherer []ResourceGatherer + ResourceSource []ResourceSource + ResourceStorage []ResourceStorage + Spawn []Spawn } func (g Game) Now() time.Time { 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.Selection = append(g.Selection, components.Selection) + 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) +} + // TODO: This could be a maintained index func (g *Game) getMoveMap() physics.MoveMap { + required := ecs.CM_Position blocked := map[physics.Point]bool{} - for _, e := range g.Entities { - if e.Position.IsEnabled { - blocked[e.Position.Value] = true + for i, mask := range g.Mask { + if mask&required == required { + blocked[g.Position[i]] = true } } return physics.MoveMap{Width: 3200, Height: 2400, Blocked: blocked} } // TODO: This could be a maintained index -func (g *Game) entityAt(position physics.Point) *Entity { - for _, e := range g.Entities { - if e.Position.IsEnabled && e.Position.Value == position { - return e +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 nil + 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 ( @@ -71,58 +113,38 @@ const ( func (g *Game) updateSelecting(cursor physics.Point, moveMap physics.MoveMap) { if inpututil.IsKeyJustReleased(ebiten.KeyEscape) { - g.Selection.Unselect() + g.GlobalSelection.Unselect(g.Selection) } if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { - g.Selection.Start(cursor) + g.GlobalSelection.Start(cursor) } if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight) { destination := cursor.Div(100).Mul(100) - entityAtDestination := g.entityAt(destination) slog.Info("destination", slog.String("destination", destination.String())) - for _, e := range g.Entities { - if e.Selection.IsEnabled && e.Selection.Value.IsSelected { - e.MainAction(g, destination, entityAtDestination, moveMap) - } + for _, i := range g.GlobalSelection.Selected { + MainAction(g, i, destination, moveMap) } } if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) { - if g.Selection.IsActive(cursor) { - // TODO: simplify with component columns - targets := make([]selection.SelectTarget, len(g.Entities)) - for i, e := range g.Entities { - targets[i] = selection.SelectTarget{ - Selection: &e.Selection, - Position: e.Position, - Bounds: e.RelBounds, - } - } - if g.Selection.IsArea(cursor) { - g.Selection.SelectMultiple(cursor, targets) + if g.GlobalSelection.IsActive(cursor) { + if g.GlobalSelection.IsArea(cursor) { + g.GlobalSelection.SelectMultiple(cursor, g.Mask, g.Selection, g.Position, g.RelBounds) } else { - g.Selection.SelectSingle(cursor, targets) + g.GlobalSelection.SelectSingle(cursor, g.Mask, g.Selection, g.Position, g.RelBounds) } } } if inpututil.IsKeyJustReleased(KeySpawnRequest) { - for _, e := range g.Entities { - if !e.Selection.IsEnabled || !e.Selection.Value.IsSelected || !e.Spawn.IsEnabled { - continue - } - e.Spawn.Value.AddRequest(g) - } - } -} - -func (g *Game) updatePatrolling(cursor physics.Point) { - if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight) { - destination := cursor.Div(100).Mul(100) - for _, e := range g.Entities { - if e.Selection.IsEnabled && e.Selection.Value.IsSelected { - Patrol(e.Position, e.Move, &e.Order, destination) + 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()}) + } } } - g.CurrentAction = Selecting } } @@ -141,13 +163,16 @@ func (g *Game) Update() error { case Selecting: g.updateSelecting(cursor, g.getMoveMap()) case Patrolling: - g.updatePatrolling(cursor) - } - for _, e := range g.Entities { - physics.UpdateMove(&e.Move, &e.Position, g.getMoveMap()) - e.UpdateOrder(g) - UpdateSpawn(g, &e.Spawn, e.Position) + if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight) { + destination := cursor.Div(100).Mul(100) + StartPatrolSystem(destination, g.Mask, g.Position, g.Move, g.Order, g.PatrolOrder) + g.CurrentAction = Selecting + } } + physics.UpdateMoveSystem(g.getMoveMap(), 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) return nil } @@ -167,7 +192,7 @@ func main() { } logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) slog.SetDefault(slog.New(logHandler)) - ebiten.SetWindowSize(640, 480) + ebiten.SetWindowSize(960, 640) ebiten.SetWindowTitle("Age of Empire") icon, err := os.Open("icon-crop.jpg") kcore.Expect(err, "failed to open icon") @@ -181,16 +206,14 @@ func main() { s, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF)) kcore.Expect(err, "failed to create font source") game.FaceSource = s - ironMine := NewEntityBuilder().WithPosition(physics.Point{X: 1000, Y: 1000}).WithSolid(NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x80, 0x80, 0x80, 0xff})).WithResourceSource(1000).WithSelection("square", selection.Building).Build() - game.Entities = append(game.Entities, &ironMine) - townCenter := NewEntityBuilder().WithPosition(physics.Point{X: 1000, Y: 2000}).WithSolid(NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x0, 0x0, 0xff, 0xff})).WithResourceStorage().WithSelection("square", selection.Building).WithSpawn(NewSpawn(50, 1*time.Second)).Build() - game.Entities = append(game.Entities, &townCenter) + ironMineBuilder := NewEntityBuilder().WithSolid(NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x80, 0x80, 0x80, 0xff})).WithResourceSource(1000).WithSelection("square", selection.Building) + game.Append(ironMineBuilder.WithPosition(physics.Point{X: 1000, Y: 1000}).Build()) + townCenterBuilder := NewEntityBuilder().WithSolid(NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x0, 0x0, 0xff, 0xff})).WithResourceStorage().WithSelection("square", selection.Building).WithSpawn(NewSpawn(50, 1*time.Second)) + townCenter := game.Append(townCenterBuilder.WithPosition(physics.Point{X: 1000, Y: 2000}).Build()) game.UnitBuilder = NewEntityBuilder().WithSolid(NewFilledCircleImage(100, color.White)).WithSelection("round", selection.Unit).WithMove().WithOrder().WithResourceGatherer(15) for i := 0; i < 0; i++ { - spawnPosition, _ := physics.Closest(townCenter.Position.Value, physics.AdjacentPoints(townCenter.Position.Value), game.getMoveMap()) - person := game.UnitBuilder.Build() - person.Position = ecs.C(spawnPosition) - game.Entities = append(game.Entities, &person) + spawnPosition, _ := physics.Closest(game.Position[townCenter], physics.AdjacentPoints(game.Position[townCenter]), game.getMoveMap()) + game.Append(game.UnitBuilder.WithPosition(spawnPosition).Build()) } game.CurrentAction = Selecting ebiten.SetRunnableOnUnfocused(true) diff --git a/monad/monad.go b/monad/monad.go new file mode 100644 index 0000000..43fb241 --- /dev/null +++ b/monad/monad.go @@ -0,0 +1,10 @@ +package monad + +type Option[T any] struct { + Ok bool + Value T +} + +func Some[T any](value T) Option[T] { + return Option[T]{Ok: true, Value: value} +} diff --git a/order.go b/order.go index d6f1ccb..9c3d643 100644 --- a/order.go +++ b/order.go @@ -1,37 +1,41 @@ package main import ( + "log/slog" + "age_of_empires/ecs" + "age_of_empires/monad" "age_of_empires/physics" - "log/slog" ) -type Order interface { - Update(e *Entity, g *Game) -} +type OrderKind int -func (e *Entity) UpdateOrder(g *Game) { - if !e.Order.IsEnabled || e.Order.Value == nil { - return - } - e.Order.Value.Update(e, g) -} +const ( + OrderKindNone OrderKind = iota + OrderKindPatrol + OrderKindGather +) -func (e *Entity) MainAction(g *Game, destination physics.Point, entityAtDestination *Entity, moveMap physics.MoveMap) { - if entityAtDestination != nil && entityAtDestination.ResourceSource.IsEnabled && e.ResourceGatherer.IsEnabled { - Gather(e, entityAtDestination, g) +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 } - if e.Move.IsEnabled { - if e.Order.IsEnabled { - e.Order.Value = nil - } - physics.StartMove(&e.Move, e.Position, destination, moveMap) + 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 e.Spawn.IsEnabled { + if mask&ecs.CM_Spawn == ecs.CM_Spawn { slog.Info("Setting spawn target") - e.Spawn.Value.SpawnTarget = ecs.C(destination) + spawn := &g.Spawn[mainEntity] + spawn.SpawnTarget = monad.Some(target) return } } diff --git a/patrol_order.go b/patrol_order.go index a5b8d83..e921e5b 100644 --- a/patrol_order.go +++ b/patrol_order.go @@ -12,24 +12,35 @@ type PatrolOrder struct { destination physics.Point } -func (o *PatrolOrder) Update(e *Entity, g *Game) { - if e.Position.IsEnabled && e.Move.IsEnabled { - if !e.Move.Value.IsActive { - moveMap := g.getMoveMap() - if e.Position.Value == o.destination { - physics.StartMove(&e.Move, e.Position, o.origin, moveMap) - } else { - physics.StartMove(&e.Move, e.Position, o.destination, moveMap) +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 Patrol(position ecs.Component[physics.Point], move ecs.Component[physics.Move], order *ecs.Component[Order], destination physics.Point) { - if !position.IsEnabled || !move.IsEnabled || !order.IsEnabled { - return +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 + } } - origin := position.Value - slog.Info("patrolling between", slog.String("origin", origin.String()), slog.String("destination", destination.String())) - order.Value = &PatrolOrder{origin: origin, destination: destination} } diff --git a/physics/move.go b/physics/move.go index 69d3c77..94da69f 100644 --- a/physics/move.go +++ b/physics/move.go @@ -1,10 +1,11 @@ package physics import ( - "age_of_empires/ecs" "fmt" "math" + "age_of_empires/ecs" + "golang.org/x/exp/slices" "golang.org/x/exp/slog" ) @@ -165,49 +166,50 @@ func (p *Path) isValid(moveMap MoveMap) bool { 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) +func moveToward(position Point, destination Point) Point { + remainingMove := destination.Sub(position) delta := remainingMove.Mul(MOVE_SPEED).Div(int(Length(remainingMove))) - position.Value = position.Value.Add(delta) + return position.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 { - moveToward(position, next) - return - } - 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())) - return - } - if remainingPath.isValid(moveMap) { - move.Value.Path = remainingPath - return - } - // 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 - return +func UpdateMoveSystem(moveMap MoveMap, masks []ecs.Mask, moves []Move, positions []Point) { + required := ecs.CM_Position | ecs.CM_Move + for i, mask := range masks { + if mask&required != required { + continue + } + move := &moves[i] + position := &positions[i] + if !move.IsActive { + continue + } + if len(move.Path) == 0 { + move.IsActive = false + continue + } + next := move.Path[0] + if Distance(*position, next) >= MOVE_SPEED { + *position = moveToward(*position, next) + continue + } + *position = next + remainingPath := move.Path[1:] + if next == move.Destination { + move.IsActive = false + slog.Info("move finished", slog.String("destination", move.Destination.String())) + continue + } + if remainingPath.isValid(moveMap) { + move.Path = remainingPath + continue + } + // Here maybe the destination has to change + path, ok := SearchPath(next, move.Destination, moveMap) + if !ok { + slog.Info("no path found", slog.String("destination", move.Destination.String())) + move.IsActive = false + continue + } + move.Path = path[1:] } - move.Value.Path = path[1:] } diff --git a/resource.go b/resource.go index d52a438..5fb48ae 100644 --- a/resource.go +++ b/resource.go @@ -2,6 +2,8 @@ package main import ( "time" + + "age_of_empires/ecs" ) type ResourceSource struct { @@ -11,7 +13,7 @@ type ResourceSource struct { type ResourceGatherer struct { MaxCapacity int CurrentVolume int - CurrentTarget *Entity + Source ecs.Entity LastPickupTime time.Time } diff --git a/selection/selection.go b/selection/selection.go index 482199c..612e212 100644 --- a/selection/selection.go +++ b/selection/selection.go @@ -26,11 +26,16 @@ type Selection struct { Priority Priority } +type selectItem struct { + ecs.Entity + *Selection +} + type GlobalSelection struct { - isActive bool - start physics.Point - buffer []*ecs.Component[Selection] - selection []*ecs.Component[Selection] + isActive bool + start physics.Point + buffer []selectItem + Selected []ecs.Entity } func (s GlobalSelection) IsActive(cursor physics.Point) bool { @@ -52,9 +57,9 @@ func (s *GlobalSelection) Start(point physics.Point) { } type SelectTarget struct { - Selection *ecs.Component[Selection] - Position ecs.Component[physics.Point] - Bounds ecs.Component[physics.Rectangle] + Selection *Selection + Position physics.Point + Bounds physics.Rectangle } func (s GlobalSelection) Bounds(cursor physics.Point) physics.Rectangle { @@ -64,14 +69,19 @@ func (s GlobalSelection) Bounds(cursor physics.Point) physics.Rectangle { }.Canon() } -func (s *GlobalSelection) SelectMultiple(cursor physics.Point, components []SelectTarget) { - s.buffer = make([]*ecs.Component[Selection], 0) - s.selection = make([]*ecs.Component[Selection], 0) - for _, target := range components { - if target.Selection.IsEnabled && target.Position.IsEnabled && target.Bounds.IsEnabled { - target.Selection.Value.IsSelected = false - if s.Bounds(cursor).Overlaps(physics.Translate(target.Bounds.Value, target.Position.Value)) { - s.buffer = append(s.buffer, target.Selection) +func (s *GlobalSelection) SelectMultiple(cursor physics.Point, masks []ecs.Mask, selections []Selection, positions []physics.Point, bounds []physics.Rectangle) { + s.buffer = make([]selectItem, 0) + s.Selected = make([]ecs.Entity, 0) + required := ecs.CM_Position | ecs.CM_RelBounds | ecs.CM_Selection + for i, mask := range masks { + if mask&required == required { + selection := &selections[i] + position := positions[i] + bound := bounds[i] + + selection.IsSelected = false + if s.Bounds(cursor).Overlaps(physics.Translate(bound, position)) { + s.buffer = append(s.buffer, selectItem{ecs.Entity(i), selection}) } } } @@ -79,40 +89,49 @@ func (s *GlobalSelection) SelectMultiple(cursor physics.Point, components []Sele return } highestPriority := Priority(math.MaxInt) - for _, selection := range s.buffer { - if selection.Value.Priority < highestPriority { - highestPriority = selection.Value.Priority + for _, selectable := range s.buffer { + if selectable.Priority < highestPriority { + highestPriority = selectable.Priority } } - for _, selection := range s.buffer { - if selection.Value.Priority == highestPriority { - selection.Value.IsSelected = true - s.selection = append(s.selection, selection) + for _, selectable := range s.buffer { + if selectable.Priority == highestPriority { + selectable.IsSelected = true + s.Selected = append(s.Selected, selectable.Entity) } } s.Clear() } -func (s *GlobalSelection) SelectSingle(cursor physics.Point, components []SelectTarget) { - s.selection = []*ecs.Component[Selection]{} - for _, target := range components { - if target.Selection.IsEnabled && target.Position.IsEnabled && target.Bounds.IsEnabled { - target.Selection.Value.IsSelected = false - if cursor.In(physics.Translate(target.Bounds.Value, target.Position.Value)) { - if len(s.selection) == 0 || target.Selection.Value.Priority > s.selection[0].Value.Priority { - s.selection = []*ecs.Component[Selection]{target.Selection} +func (s *GlobalSelection) SelectSingle(cursor physics.Point, masks []ecs.Mask, selections []Selection, positions []physics.Point, bounds []physics.Rectangle) { + s.Selected = []ecs.Entity{} + s.buffer = []selectItem{} + required := ecs.CM_Position | ecs.CM_RelBounds | ecs.CM_Selection + for i, mask := range masks { + if mask&required == required { + selection := &selections[i] + position := positions[i] + bound := bounds[i] + selection.IsSelected = false + if cursor.In(physics.Translate(bound, position)) { + if len(s.buffer) == 0 || selection.Priority > s.buffer[0].Priority { + s.buffer = []selectItem{{ecs.Entity(i), selection}} } } } } + if len(s.buffer) > 0 { + s.buffer[0].IsSelected = true + s.Selected = []ecs.Entity{s.buffer[0].Entity} + } s.Clear() } -func (s *GlobalSelection) Unselect() { - for _, selection := range s.selection { - selection.Value.IsSelected = false +func (s *GlobalSelection) Unselect(selections []Selection) { + for _, i := range s.Selected { + selections[i].IsSelected = false } - s.selection = []*ecs.Component[Selection]{} + s.Selected = []ecs.Entity{} } func (s *GlobalSelection) Draw(dst *ebiten.Image, cursor physics.Point) { diff --git a/spawn.go b/spawn.go index d383d7e..87624fb 100644 --- a/spawn.go +++ b/spawn.go @@ -5,55 +5,49 @@ import ( "time" "age_of_empires/ecs" + "age_of_empires/monad" "age_of_empires/physics" ) -type spawnRequest struct { - start time.Time +type SpawnRequest struct { + Start time.Time } type Spawn struct { UnitResourceCost int UnitSpawnDuration time.Duration - Requests []spawnRequest - SpawnTarget ecs.Component[physics.Point] + 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), + Requests: make([]SpawnRequest, 0), } } -func (spawn *Spawn) AddRequest(g *Game) { - if g.ResourceAmount < spawn.UnitResourceCost { - return - } - g.ResourceAmount -= spawn.UnitResourceCost - spawn.Requests = append(spawn.Requests, spawnRequest{start: g.Now()}) -} - -func UpdateSpawn(g *Game, spawn *ecs.Component[Spawn], position ecs.Component[physics.Point]) { - if !spawn.IsEnabled || !position.IsEnabled { - return - } - now := g.Now() - if len(spawn.Value.Requests) == 0 || now.Sub(spawn.Value.Requests[0].start) < time.Duration(spawn.Value.UnitSpawnDuration) { - return - } - spawnPosition, _ := physics.Closest(position.Value, physics.AdjacentPoints(position.Value), g.getMoveMap()) - unit := g.UnitBuilder.Build() - unit.Position = ecs.C(spawnPosition) - g.Entities = append(g.Entities, &unit) - slog.Info("Spawned unit") - if spawn.Value.SpawnTarget.IsEnabled { - slog.Info("Unit has spawn target") - unit.MainAction(g, spawn.Value.SpawnTarget.Value, g.entityAt(spawn.Value.SpawnTarget.Value), g.getMoveMap()) - } - spawn.Value.Requests = spawn.Value.Requests[1:] - if len(spawn.Value.Requests) > 0 { - spawn.Value.Requests[0].start = now +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 + } + } } } -- cgit v1.3