aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-01-15 22:05:38 +0100
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-01-15 22:05:46 +0100
commit13626410c4d6db7fdc15ded5ee7865fcdbf0b88a (patch)
treea549d918804acb7682c857e742f1af189692cf62
parentcf85fc095bc9b6ab4b49e4f88a5f04d7ac2fe74b (diff)
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.
-rw-r--r--README.md5
-rw-r--r--draw.go132
-rw-r--r--ecs/component.go22
-rw-r--r--ecs/entity.go3
-rw-r--r--entity.go70
-rw-r--r--gather_order.go95
-rw-r--r--main.go153
-rw-r--r--monad/monad.go10
-rw-r--r--order.go44
-rw-r--r--patrol_order.go39
-rw-r--r--physics/move.go86
-rw-r--r--resource.go4
-rw-r--r--selection/selection.go87
-rw-r--r--spawn.go60
14 files changed, 440 insertions, 370 deletions
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
+ }
+ }
}
}