aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--docs/examples-catalog.md253
-rw-r--r--engine/camera.go (renamed from camera.go)2
-rw-r--r--engine/draw.go (renamed from draw.go)2
-rw-r--r--engine/entity.go (renamed from entity.go)2
-rw-r--r--engine/game.go180
-rw-r--r--engine/gather_order.go (renamed from gather_order.go)2
-rw-r--r--engine/image.go (renamed from image.go)2
-rw-r--r--engine/input.go (renamed from input.go)2
-rw-r--r--engine/integration_test.go (renamed from integration_test.go)2
-rw-r--r--engine/order.go (renamed from order.go)4
-rw-r--r--engine/patrol_order.go (renamed from patrol_order.go)2
-rw-r--r--engine/resource.go (renamed from resource.go)2
-rw-r--r--engine/spawn.go (renamed from spawn.go)6
-rw-r--r--examples/combat.go42
-rw-r--r--examples/default.go40
-rw-r--r--examples/examples.go20
-rw-r--r--main.go227
18 files changed, 593 insertions, 199 deletions
diff --git a/README.md b/README.md
index 6f07ced..4a4ee87 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+Run `go run . example` to browse and launch curated gameplay scenarios (combat, gathering, etc.)
+
## Debugging
- Restart after kernel update until `glxinfo | grep "OpenGL version"` works
diff --git a/docs/examples-catalog.md b/docs/examples-catalog.md
new file mode 100644
index 0000000..aeee0a0
--- /dev/null
+++ b/docs/examples-catalog.md
@@ -0,0 +1,253 @@
+# Examples Catalog — Architecture
+
+## 0. Solution (TL;DR)
+
+Add an `examples/` package with a registry of named scenarios. Each example is a function that populates a `*Game` with entities, positions, etc. The `example` subcommand selects which scenario to load. When no subcommand is given, the `"default"` example runs. No new dependencies, no structural changes to the engine.
+
+```
+go run . example # list available examples
+go run . example combat # launches a combat demo
+go run . # launches the default world (via registry)
+```
+
+## 1. Context & scope
+
+### Current landscape
+
+The game has an ECS engine, a camera system, pathfinding, resource gathering, unit spawning, patrol orders, and an Input abstraction layer. But currently, the "world" is hardcoded in `main()`:
+
+```go
+// main.go — hardcoded world setup
+ironMineBuilder := NewEntityBuilder()...
+game.Append(ironMineBuilder.WithPosition(...).Build())
+townCenterBuilder := NewEntityBuilder()...
+townCenter := game.Append(townCenterBuilder.WithPosition(...).Build())
+// ...
+```
+
+Every feature addition bloats `main()` further. There is no way to:
+
+- Demonstrate a specific feature in isolation
+- Quickly iterate on a scenario without rebuilding everything
+- Show the game to someone without them seeing a crowded test world
+
+### What is being built
+
+An **examples catalog** — a lightweight framework under `examples/` where each scenario is a self-contained Go file that builds a world. The CLI entry point (`main.go`) dispatches to the chosen example, or runs `"default"` when no subcommand is given.
+
+Future examples might demonstrate:
+
+- Combat between two groups of units
+- Resource gathering loop (mine → carry → deposit)
+- Patrol routes along a border
+- Mass spawning from multiple town centers
+- A "tech demo" with every entity type on screen
+
+## 2. Goals & non-goals
+
+### Goals
+
+- **CLI dispatch:** `go run . example combat` loads the combat scenario; `go run .` → `"default"` example from the registry
+- **Self-contained examples:** Each example file owns its entity setup, positioning, and initial camera position — including `"default"`
+- **Feature independence:** Examples live outside `main.go` and don't require changes to core engine files
+- **Discoverability:** `go run . example` (no name) prints all registered examples with descriptions
+- **Minimal wiring:** Zero boilerplate beyond adding a file and registering it
+
+### Non-goals
+
+- Not a scripting system — examples are compiled Go, not JSON/YAML data files
+- Not a map editor — the goal is code-driven scenario setup, not a GUI tool
+- Not a save/load system — examples are static worlds, not persisted state
+- Not a modding API — only Go code, no plugin boundary
+
+## 3. Design
+
+### Architecture overview
+
+```mermaid
+flowchart LR
+ A[main.go] -->|example combat| B{examples.Registry}
+ B -->|lookup "combat"| C[combat.Build]
+ C -->|Append entities| D[Game]
+ D -->|ebiten.RunGame| E[Rendered world]
+```
+
+### Registry
+
+A single map that maps example name → builder function:
+
+```
+examples/registry.go
+ type Builder func(*Game, BuilderDeps)
+ type BuilderDeps struct { UnitBuilder EntityBuilder; ... }
+ var Registry = map[string]struct{
+ Name string
+ Description string
+ Build Builder
+ }
+```
+
+The `BuilderDeps` struct carries shared resources (font, pre-built entity builders) so each example doesn't recreate them. The example only calls `game.Append(...)` and optionally sets `game.Camera`.
+
+### Example structure (combat)
+
+```go
+// examples/combat.go
+package examples
+
+import "age_of_empires/..."
+
+func init() {
+ Registry["combat"] = Entry{
+ Name: "Combat",
+ Description: "Two groups of units fight each other",
+ Build: buildCombat,
+ }
+}
+
+func buildCombat(g *Game, deps BuilderDeps) {
+ // Red team
+ redBuilder := deps.UnitBuilder.WithSolid(NewFilledCircleImage(100, color.RGBA{0xff,0,0,0xff}))
+ for x := 0; x < 5; x++ {
+ g.Append(redBuilder.WithPosition(physics.Point{X: 400 + x*120, Y: 800}).Build())
+ }
+ // Blue team
+ blueBuilder := deps.UnitBuilder.WithSolid(NewFilledCircleImage(100, color.RGBA{0,0,0xff,0xff}))
+ for x := 0; x < 5; x++ {
+ g.Append(blueBuilder.WithPosition(physics.Point{X: 400 + x*120, Y: 1200}).Build())
+ }
+ // Camera position
+ g.Camera = Camera{X: 800, Y: 1000, Zoom: 1.0}
+}
+```
+
+### CLI dispatch
+
+In `main.go`, before creating the Game:
+
+1. Look at `os.Args` for the subcommand pattern: `example` or `example <name>`
+2. If `go run . example` (no name), print the registry and exit
+3. If `go run . example <name>`, look up the name in `examples.Registry`:
+ - If found, call the builder with the game pointer before `ebiten.RunGame`
+ - If not found, print error + available names and exit
+4. If no `example` subcommand (`go run .`), dispatch to `"default"` via the same registry
+
+This avoids mixing concerns: flags like `--profile` are truly cross-cutting, while `example` is a mode selector — naturally a subcommand.
+
+### Package structure
+
+```
+go_age_of_empire/
+├── examples/
+│ ├── registry.go # Registry map, Builder type, BuilderDeps
+│ ├── combat.go # Combat scenario
+│ ├── gathering.go # Resource gathering scenario
+│ └── patrol.go # Patrol route scenario
+├── main.go # CLI dispatch + default fallback
+├── camera.go
+├── ...
+```
+
+### Flow diagram
+
+```mermaid
+flowchart TD
+ Start([os.Args]) --> Subcmd{os.Args[1] == "example"?}
+ Subcmd -->|No| Lookup["Look up 'default' in examples.Registry"]
+ Subcmd -->|Yes| HasName{os.Args[2] exists?}
+ HasName -->|No| List["Print registry and exit"]
+ HasName -->|Yes| Lookup["Look up name in examples.Registry"]
+ Lookup --> Found{Found?}
+ Found -->|No| Error["Print error + available names, exit"]
+ Found -->|Yes| Build["Call example.Build(game, deps)"]
+ Build --> Run["ebiten.RunGame(game)"]
+ Default --> Run
+```
+
+### BuilderDeps vs global state
+
+Shared resources (font, entity templates) are passed through `BuilderDeps` instead of being global variables. This keeps examples testable and independent.
+
+```go
+type BuilderDeps struct {
+ FaceSource *text.GoTextFaceSource
+ UnitBuilder EntityBuilder
+ // future: BuildingBuilder, TreeBuilder, etc.
+}
+```
+
+`main.go` constructs the `BuilderDeps` once and passes it to every example.
+
+## 4. Alternatives
+
+### A — Data-driven (JSON/YAML) world definitions
+
+A JSON file per example describing entities, positions, and properties. A generic loader iterates over the data and calls `game.Append()`.
+
+| Pro | Con |
+|---|---|
+| Non-programmers can author scenarios | Can't express logic (patrol routes, timers, AI behavior) |
+| Loadable at runtime without recompilation | Type safety requires schema validation |
+| Easy to share and version | Entity composition is awkward in flat JSON |
+
+**Verdict:** Rejected for now. The examples catalog is for developers demonstrating features, and the feature logic (e.g., "spawn 5 units then patrol them") is too procedural for a data format. Data files could be added later for map data.
+
+### B — Standalone binaries per example
+
+Each example gets its own `main.go` in a subdirectory: `examples/combat/main.go`, `examples/gathering/main.go`.
+
+| Pro | Con |
+|---|---|
+| Truly independent, no dispatch code needed | Massive duplication of engine setup (font, icon, window, Ebiten boilerplate) |
+| Each can have its own `go.mod` | Can't share entity builders or utility code without a library package |
+| Clean separation | `go run ./examples/combat` is more typing than `go run . example combat` |
+
+**Verdict:** Rejected. The duplication cost outweighs the isolation benefit. A single binary with CLI dispatch is more ergonomic.
+
+### C — Plugin system (Go plugins / shared objects)
+
+Each example is compiled as a `.so` plugin loaded at runtime via `plugin.Open`.
+
+| Pro | Con |
+|---|---|
+| Examples can be added without recompiling the main binary | Linux-only, fragile ABI, not supported on all platforms |
+| | Debugging is painful |
+| | Overkill for first-party examples shipped in the same repo |
+
+**Verdict:** Rejected. Go's plugin system has too many caveats and zero advantage for in-repo examples.
+
+## 5. Open questions
+
+1. **Should the default world (current `main()` code) also be an example?**
+ - ✅ **Decision:** Yes — move it to `examples/default.go` and register it as `"default"`.
+ - When `go run .` (no subcommand), dispatch to `"default"` via the same registry.
+ - Single code path: every mode of execution goes through `examples.Registry`.
+ - `main.go` has zero world-building logic.
+
+2. **Should examples be able to add new ECS components?**
+ - ✅ **Decision:** No. New components belong in `ecs/component.go`.
+ - Examples only wire existing components into the world.
+
+3. **How should the camera initial position be set?**
+ - ✅ **Decision:** Add `Camera.SetCenter(x, y)` for clarity. Examples call `deps.SetCamera(game, x, y)` or set `game.Camera.X/Y` directly.
+
+4. **Should `example` without an argument or with an unknown name print the list?**
+ - ✅ **Decision:** No argument → list. Unknown name → error + list.
+
+5. **How are build resources (entity builders with specific colors/sizes) shared?**
+ - ✅ **Decision:** `BuilderDeps` holds a base `UnitBuilder`. Examples clone it and call `.WithSolid(...)` to customize color/size.
+
+## 6. Parties involved
+
+- **Author:** Martin (implementing the examples)
+- **Consumers:** Anyone who wants to quickly see a feature in action (demo purposes, debugging, onboarding)
+- **Future contributors:** Adding a new example should be a single-file change with no engine modifications
+
+## 7. Cross concerns
+
+| Concern | Mitigation |
+|---|---|
+| **CLI hygiene** | Use `os.Args` scanning (no external flag dependency needed); subcommand avoids conflating modes with flags. Print usage on unknown subcommand |
+| **Example isolation** | Each example is a `func(*Game, BuilderDeps)`. No mutable global state is shared between examples |
+| **Testability** | Examples can be tested by calling `example.Build(game, deps)` and asserting on `game.Mask`, `game.Position`, etc. |
+| **Observability** | `go run . example` lists examples; unknown names print error + list. The active example name is logged at startup |
diff --git a/camera.go b/engine/camera.go
index 9118117..867aaf4 100644
--- a/camera.go
+++ b/engine/camera.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"math"
diff --git a/draw.go b/engine/draw.go
index e7f405f..ca605ce 100644
--- a/draw.go
+++ b/engine/draw.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"fmt"
diff --git a/entity.go b/engine/entity.go
index 40ca0ea..7aa43b1 100644
--- a/entity.go
+++ b/engine/entity.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"image/color"
diff --git a/engine/game.go b/engine/game.go
new file mode 100644
index 0000000..c5699d2
--- /dev/null
+++ b/engine/game.go
@@ -0,0 +1,180 @@
+package engine
+
+import (
+ "image/color"
+ "time"
+
+ "age_of_empires/ecs"
+ "age_of_empires/physics"
+ "age_of_empires/selection"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/text/v2"
+
+ "golang.org/x/exp/slog"
+)
+
+type Action string
+
+const (
+ Selecting Action = "selecting"
+ Patrolling Action = "patrolling"
+)
+
+var soilColor = color.RGBA{0x60, 0x40, 0x20, 0xff}
+
+type Game struct {
+ CurrentAction Action
+ Camera Camera
+ GlobalSelection selection.GlobalSelection
+ ResourceAmount int
+ FaceSource *text.GoTextFaceSource
+ UnitBuilder EntityBuilder
+ NowFunc func() time.Time
+ // Components
+ Mask []ecs.Mask
+ // Entities []*Entity
+ Position []physics.Point
+ RelBounds []physics.Rectangle
+ Image []*ebiten.Image
+ Selectable []selection.Selectable
+ Move []physics.Move
+ Order []OrderKind
+ PatrolOrder []PatrolOrder
+ ResourceGatherer []ResourceGatherer
+ ResourceSource []ResourceSource
+ ResourceStorage []ResourceStorage
+ Spawn []Spawn
+}
+
+func (g Game) Now() time.Time {
+ if g.NowFunc != nil {
+ return g.NowFunc()
+ }
+ return time.Now()
+}
+
+func (g *Game) Append(components Components, mask ecs.Mask) ecs.Entity {
+ g.Mask = append(g.Mask, mask)
+ g.Position = append(g.Position, components.Position)
+ g.RelBounds = append(g.RelBounds, components.RelBounds)
+ g.Image = append(g.Image, components.Image)
+ g.Selectable = append(g.Selectable, components.Selectable)
+ g.Move = append(g.Move, components.Move)
+ g.Order = append(g.Order, components.Order)
+ g.ResourceGatherer = append(g.ResourceGatherer, components.ResourceGatherer)
+ g.ResourceSource = append(g.ResourceSource, components.ResourceSource)
+ g.ResourceStorage = append(g.ResourceStorage, components.ResourceStorage)
+ g.Spawn = append(g.Spawn, components.Spawn)
+ return ecs.Entity(len(g.Mask) - 1)
+}
+
+func (g *Game) GetMoveMap() physics.MoveMap {
+ required := ecs.CM_Position
+ blocked := map[physics.Point]bool{}
+ for i, mask := range g.Mask {
+ if mask&required == required {
+ blocked[g.Position[i]] = true
+ }
+ }
+ return physics.MoveMap{Width: WorldWidth, Height: WorldHeight, Blocked: blocked}
+}
+
+func (g *Game) entityAt(position physics.Point) ecs.Entity {
+ required := ecs.CM_Position
+ for i, mask := range g.Mask {
+ if mask&required == required && g.Position[i] == position {
+ return ecs.Entity(i)
+ }
+ }
+ return -1
+}
+
+func getAllStorageDockings(g *Game) []physics.Point {
+ storageDockings := []physics.Point{}
+ required := ecs.CM_Position | ecs.CM_ResourceStorage
+ for i, mask := range g.Mask {
+ if mask&required == required {
+ position := g.Position[i]
+ storageDockings = append(storageDockings, physics.AdjacentPoints(position)...)
+ }
+ }
+ return storageDockings
+}
+
+const (
+ KeySpawnRequest = ebiten.KeyS
+ KeyPatrolRequest = ebiten.KeyQ
+)
+
+func (g *Game) updateSelecting(input Input, moveMap physics.MoveMap) {
+ if input.LeftMouseDown {
+ g.GlobalSelection.Start(input.Cursor)
+ g.GlobalSelection.StartScreen(input.ScreenCursor)
+ }
+ if input.RightMouseUp {
+ destination := input.Cursor.Div(100).Mul(100)
+ slog.Info("destination", slog.String("destination", destination.String()))
+ for _, i := range g.GlobalSelection.Selected {
+ MainAction(g, i, destination, moveMap)
+ }
+ }
+ if input.LeftMouseUp {
+ if g.GlobalSelection.IsActive(input.Cursor) {
+ if g.GlobalSelection.IsArea(input.Cursor) {
+ g.GlobalSelection.SelectMultiple(input.Cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
+ } else {
+ g.GlobalSelection.SelectSingle(input.Cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
+ }
+ }
+ }
+ if input.SpawnPressed {
+ required := ecs.CM_Spawn
+ for _, i := range g.GlobalSelection.Selected {
+ if g.Mask[i]&required == required {
+ spawn := &g.Spawn[i]
+ if g.ResourceAmount >= spawn.UnitResourceCost {
+ g.ResourceAmount -= spawn.UnitResourceCost
+ spawn.Requests = append(spawn.Requests, SpawnRequest{Start: g.Now()})
+ }
+ }
+ }
+ }
+}
+
+func (g *Game) UpdateSimulation(input Input) {
+ moveMap := g.GetMoveMap()
+
+ if input.EscapePressed {
+ g.GlobalSelection.Unselect(g.Selectable)
+ g.CurrentAction = Selecting
+ } else if input.PatrolPressed {
+ g.CurrentAction = Patrolling
+ }
+
+ switch g.CurrentAction {
+ case Selecting:
+ g.updateSelecting(input, moveMap)
+ case Patrolling:
+ if input.RightMouseUp {
+ destination := input.Cursor.Div(100).Mul(100)
+ StartPatrolSystem(destination, g.Mask, g.Position, g.Move, g.Order, g.PatrolOrder)
+ g.CurrentAction = Selecting
+ }
+ }
+
+ physics.UpdateMoveSystem(moveMap, g.Mask, g.Move, g.Position)
+ UpdateSpawnSystem(g, g.Now(), g.Mask, g.Spawn, g.Position)
+ UpdateGatherSystem(g, g.Now(), g.GetMoveMap(), g.Mask, g.Order, g.Position, g.Move, g.ResourceGatherer)
+ UpdatePatrolSystem(g.GetMoveMap(), g.Mask, g.Order, g.PatrolOrder, g.Position, g.Move)
+}
+
+func (g *Game) Update() error {
+ g.Camera.Update(1.0 / 60.0)
+ g.UpdateSimulation(CollectInput(&g.Camera))
+ return nil
+}
+
+func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
+ return ScreenWidth, ScreenHeight
+}
diff --git a/gather_order.go b/engine/gather_order.go
index fd56fbe..e0c71fb 100644
--- a/gather_order.go
+++ b/engine/gather_order.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"math"
diff --git a/image.go b/engine/image.go
index 50f1a19..07cac55 100644
--- a/image.go
+++ b/engine/image.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"age_of_empires/physics"
diff --git a/input.go b/engine/input.go
index 5b9be32..5d03b08 100644
--- a/input.go
+++ b/engine/input.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"age_of_empires/physics"
diff --git a/integration_test.go b/engine/integration_test.go
index 7d687fb..b999b45 100644
--- a/integration_test.go
+++ b/engine/integration_test.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"image/color"
diff --git a/order.go b/engine/order.go
index 9c3d643..8aa991b 100644
--- a/order.go
+++ b/engine/order.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"log/slog"
@@ -22,7 +22,7 @@ func MainAction(g *Game, mainEntity ecs.Entity, target physics.Point, moveMap ph
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])
+ Gather(g.GetMoveMap(), g.Position[mainEntity], &g.Move[mainEntity], &g.Order[mainEntity], &g.ResourceGatherer[mainEntity], targetEntity, g.Position[targetEntity])
return
}
moveRequired := ecs.CM_Move | ecs.CM_Position
diff --git a/patrol_order.go b/engine/patrol_order.go
index e921e5b..0889f92 100644
--- a/patrol_order.go
+++ b/engine/patrol_order.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"age_of_empires/ecs"
diff --git a/resource.go b/engine/resource.go
index 5fb48ae..406750c 100644
--- a/resource.go
+++ b/engine/resource.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"time"
diff --git a/spawn.go b/engine/spawn.go
index 87624fb..fc32a84 100644
--- a/spawn.go
+++ b/engine/spawn.go
@@ -1,4 +1,4 @@
-package main
+package engine
import (
"log/slog"
@@ -37,12 +37,12 @@ func UpdateSpawnSystem(g *Game, now time.Time, masks []ecs.Mask, spawns []Spawn,
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())
+ 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())
+ MainAction(g, unit, spawn.SpawnTarget.Value, g.GetMoveMap())
}
spawn.Requests = spawn.Requests[1:]
if len(spawn.Requests) > 0 {
diff --git a/examples/combat.go b/examples/combat.go
new file mode 100644
index 0000000..f2dd92f
--- /dev/null
+++ b/examples/combat.go
@@ -0,0 +1,42 @@
+package examples
+
+import (
+ "image/color"
+
+ "age_of_empires/engine"
+ "age_of_empires/physics"
+ "age_of_empires/selection"
+)
+
+func init() {
+ Registry["combat"] = Entry{
+ Name: "Combat",
+ Description: "Two groups of units facing each other — ready for battle",
+ Build: buildCombat,
+ }
+}
+
+func buildCombat(g *engine.Game, deps BuilderDeps) {
+ g.ResourceAmount = 500
+ g.Camera = engine.Camera{X: 800, Y: 1000, Zoom: 1.0}
+ g.CurrentAction = engine.Selecting
+ g.UnitBuilder = deps.UnitBuilder
+
+ // Red team (left)
+ redBuilder := engine.NewEntityBuilder().
+ WithSolid(engine.NewFilledCircleImage(100, color.RGBA{0xff, 0x00, 0x00, 0xff})).
+ WithSelectable("round", selection.Unit).
+ WithMove().WithOrder().WithResourceGatherer(15)
+ for x := 0; x < 5; x++ {
+ g.Append(redBuilder.WithPosition(physics.Point{X: 200 + x*120, Y: 1000}).Build())
+ }
+
+ // Blue team (right)
+ blueBuilder := engine.NewEntityBuilder().
+ WithSolid(engine.NewFilledCircleImage(100, color.RGBA{0x00, 0x00, 0xff, 0xff})).
+ WithSelectable("round", selection.Unit).
+ WithMove().WithOrder().WithResourceGatherer(15)
+ for x := 0; x < 5; x++ {
+ g.Append(blueBuilder.WithPosition(physics.Point{X: 1000 + x*120, Y: 1000}).Build())
+ }
+}
diff --git a/examples/default.go b/examples/default.go
new file mode 100644
index 0000000..d291a26
--- /dev/null
+++ b/examples/default.go
@@ -0,0 +1,40 @@
+package examples
+
+import (
+ "image/color"
+ "time"
+
+ "age_of_empires/engine"
+ "age_of_empires/physics"
+ "age_of_empires/selection"
+)
+
+func init() {
+ Registry["default"] = Entry{
+ Name: "Default",
+ Description: "Free play with a town center, iron mine, and gatherer units",
+ Build: buildDefault,
+ }
+}
+
+func buildDefault(g *engine.Game, deps BuilderDeps) {
+ g.ResourceAmount = 150
+ g.Camera = engine.Camera{X: 1000, Y: 1000, Zoom: 1.0}
+ g.CurrentAction = engine.Selecting
+ g.UnitBuilder = deps.UnitBuilder
+
+ ironMineBuilder := engine.NewEntityBuilder().
+ WithSolid(engine.NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x80, 0x80, 0x80, 0xff})).
+ WithResourceSource(1000).
+ WithSelectable("square", selection.Building)
+ g.Append(ironMineBuilder.WithPosition(physics.Point{X: 1000, Y: 1000}).Build())
+
+ townCenterBuilder := engine.NewEntityBuilder().
+ WithSolid(engine.NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x0, 0x0, 0xff, 0xff})).
+ WithResourceStorage().
+ WithSelectable("square", selection.Building).
+ WithSpawn(engine.NewSpawn(50, 1*time.Second))
+ townCenter := g.Append(townCenterBuilder.WithPosition(physics.Point{X: 1000, Y: 2000}).Build())
+
+ _ = townCenter // suppress unused warning; future spawn loop will use it
+}
diff --git a/examples/examples.go b/examples/examples.go
new file mode 100644
index 0000000..9e18d6b
--- /dev/null
+++ b/examples/examples.go
@@ -0,0 +1,20 @@
+package examples
+
+import (
+ "age_of_empires/engine"
+
+ "github.com/hajimehoshi/ebiten/v2/text/v2"
+)
+
+type BuilderDeps struct {
+ FaceSource *text.GoTextFaceSource
+ UnitBuilder engine.EntityBuilder
+}
+
+type Entry struct {
+ Name string
+ Description string
+ Build func(*engine.Game, BuilderDeps)
+}
+
+var Registry = map[string]Entry{}
diff --git a/main.go b/main.go
index f0c7e51..20015d9 100644
--- a/main.go
+++ b/main.go
@@ -2,187 +2,28 @@ package main
import (
"bytes"
+ "fmt"
"image"
"image/color"
_ "image/jpeg"
"os"
"runtime/pprof"
- "time"
- "age_of_empires/ecs"
- "age_of_empires/physics"
+ "age_of_empires/engine"
+ "age_of_empires/examples"
"age_of_empires/selection"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/martinlehoux/kagamigo/kcore"
"golang.org/x/exp/slog"
-
-)
-
-type Action string
-
-const (
- Selecting Action = "selecting"
- Patrolling Action = "patrolling"
-)
-
-var soilColor = color.RGBA{0x60, 0x40, 0x20, 0xff}
-
-type Game struct {
- CurrentAction Action
- Camera Camera
- GlobalSelection selection.GlobalSelection
- ResourceAmount int
- FaceSource *text.GoTextFaceSource
- UnitBuilder EntityBuilder
- NowFunc func() time.Time
- // Components
- Mask []ecs.Mask
- // Entities []*Entity
- Position []physics.Point
- RelBounds []physics.Rectangle
- Image []*ebiten.Image
- Selectable []selection.Selectable
- Move []physics.Move
- Order []OrderKind
- PatrolOrder []PatrolOrder
- ResourceGatherer []ResourceGatherer
- ResourceSource []ResourceSource
- ResourceStorage []ResourceStorage
- Spawn []Spawn
-}
-
-func (g Game) Now() time.Time {
- if g.NowFunc != nil {
- return g.NowFunc()
- }
- return time.Now()
-}
-
-func (g *Game) Append(components Components, mask ecs.Mask) ecs.Entity {
- g.Mask = append(g.Mask, mask)
- g.Position = append(g.Position, components.Position)
- g.RelBounds = append(g.RelBounds, components.RelBounds)
- g.Image = append(g.Image, components.Image)
- g.Selectable = append(g.Selectable, components.Selectable)
- g.Move = append(g.Move, components.Move)
- g.Order = append(g.Order, components.Order)
- g.ResourceGatherer = append(g.ResourceGatherer, components.ResourceGatherer)
- g.ResourceSource = append(g.ResourceSource, components.ResourceSource)
- g.ResourceStorage = append(g.ResourceStorage, components.ResourceStorage)
- g.Spawn = append(g.Spawn, components.Spawn)
- return ecs.Entity(len(g.Mask) - 1)
-}
-
-func (g *Game) getMoveMap() physics.MoveMap {
- required := ecs.CM_Position
- blocked := map[physics.Point]bool{}
- for i, mask := range g.Mask {
- if mask&required == required {
- blocked[g.Position[i]] = true
- }
- }
- return physics.MoveMap{Width: WorldWidth, Height: WorldHeight, Blocked: blocked}
-}
-
-func (g *Game) entityAt(position physics.Point) ecs.Entity {
- required := ecs.CM_Position
- for i, mask := range g.Mask {
- if mask&required == required && g.Position[i] == position {
- return ecs.Entity(i)
- }
- }
- return -1
-}
-
-func getAllStorageDockings(g *Game) []physics.Point {
- storageDockings := []physics.Point{}
- required := ecs.CM_Position | ecs.CM_ResourceStorage
- for i, mask := range g.Mask {
- if mask&required == required {
- position := g.Position[i]
- storageDockings = append(storageDockings, physics.AdjacentPoints(position)...)
- }
- }
- return storageDockings
-}
-
-const (
- KeySpawnRequest = ebiten.KeyS
- KeyPatrolRequest = ebiten.KeyQ
)
-func (g *Game) updateSelecting(input Input, moveMap physics.MoveMap) {
- if input.LeftMouseDown {
- g.GlobalSelection.Start(input.Cursor)
- g.GlobalSelection.StartScreen(input.ScreenCursor)
- }
- if input.RightMouseUp {
- destination := input.Cursor.Div(100).Mul(100)
- slog.Info("destination", slog.String("destination", destination.String()))
- for _, i := range g.GlobalSelection.Selected {
- MainAction(g, i, destination, moveMap)
- }
- }
- if input.LeftMouseUp {
- if g.GlobalSelection.IsActive(input.Cursor) {
- if g.GlobalSelection.IsArea(input.Cursor) {
- g.GlobalSelection.SelectMultiple(input.Cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
- } else {
- g.GlobalSelection.SelectSingle(input.Cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
- }
- }
- }
- if input.SpawnPressed {
- required := ecs.CM_Spawn
- for _, i := range g.GlobalSelection.Selected {
- if g.Mask[i]&required == required {
- spawn := &g.Spawn[i]
- if g.ResourceAmount >= spawn.UnitResourceCost {
- g.ResourceAmount -= spawn.UnitResourceCost
- spawn.Requests = append(spawn.Requests, SpawnRequest{Start: g.Now()})
- }
- }
- }
- }
-}
-
-func (g *Game) UpdateSimulation(input Input) {
- moveMap := g.getMoveMap()
-
- if input.EscapePressed {
- g.GlobalSelection.Unselect(g.Selectable)
- g.CurrentAction = Selecting
- } else if input.PatrolPressed {
- g.CurrentAction = Patrolling
+func listExamples() {
+ fmt.Println("Available examples:")
+ for name, entry := range examples.Registry {
+ fmt.Printf(" %-12s %s\n", name, entry.Description)
}
-
- switch g.CurrentAction {
- case Selecting:
- g.updateSelecting(input, moveMap)
- case Patrolling:
- if input.RightMouseUp {
- destination := input.Cursor.Div(100).Mul(100)
- StartPatrolSystem(destination, g.Mask, g.Position, g.Move, g.Order, g.PatrolOrder)
- g.CurrentAction = Selecting
- }
- }
-
- physics.UpdateMoveSystem(moveMap, g.Mask, g.Move, g.Position)
- UpdateSpawnSystem(g, g.Now(), g.Mask, g.Spawn, g.Position)
- UpdateGatherSystem(g, g.Now(), g.getMoveMap(), g.Mask, g.Order, g.Position, g.Move, g.ResourceGatherer)
- UpdatePatrolSystem(g.getMoveMap(), g.Mask, g.Order, g.PatrolOrder, g.Position, g.Move)
-}
-
-func (g *Game) Update() error {
- g.Camera.Update(1.0 / 60.0)
- g.UpdateSimulation(CollectInput(&g.Camera))
- return nil
-}
-
-func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
- return ScreenWidth, ScreenHeight
}
func main() {
@@ -197,7 +38,7 @@ func main() {
}
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
slog.SetDefault(slog.New(logHandler))
- ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
+ ebiten.SetWindowSize(engine.ScreenWidth, engine.ScreenHeight)
ebiten.SetWindowTitle("Age of Empire")
icon, err := os.Open("icon-crop.jpg")
kcore.Expect(err, "failed to open icon")
@@ -205,29 +46,45 @@ func main() {
iconImg, _, err := image.Decode(icon)
kcore.Expect(err, "failed to decode icon")
ebiten.SetWindowIcon([]image.Image{iconImg})
- game := &Game{
- ResourceAmount: 150,
- Camera: Camera{
- X: 1000,
- Y: 1000,
- Zoom: 1.0,
- },
- }
fontData, err := os.ReadFile("fonts/Cinzel-VariableFont_wght.ttf")
kcore.Expect(err, "failed to read font")
s, err := text.NewGoTextFaceSource(bytes.NewReader(fontData))
kcore.Expect(err, "failed to create font source")
- game.FaceSource = s
- ironMineBuilder := NewEntityBuilder().WithSolid(NewFilledRectangleImage(physics.Point{X: 100, Y: 100}, color.RGBA{0x80, 0x80, 0x80, 0xff})).WithResourceSource(1000).WithSelectable("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().WithSelectable("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)).WithSelectable("round", selection.Unit).WithMove().WithOrder().WithResourceGatherer(15)
- for i := 0; i < 0; i++ {
- spawnPosition, _ := physics.Closest(game.Position[townCenter], physics.AdjacentPoints(game.Position[townCenter]), game.getMoveMap())
- game.Append(game.UnitBuilder.WithPosition(spawnPosition).Build())
+
+ baseUnitBuilder := engine.NewEntityBuilder().
+ WithSolid(engine.NewFilledCircleImage(100, color.White)).
+ WithSelectable("round", selection.Unit).
+ WithMove().WithOrder().WithResourceGatherer(15)
+
+ game := &engine.Game{
+ FaceSource: s,
+ UnitBuilder: baseUnitBuilder,
}
- game.CurrentAction = Selecting
+
+ deps := examples.BuilderDeps{
+ FaceSource: s,
+ UnitBuilder: baseUnitBuilder,
+ }
+
+ if len(os.Args) >= 2 && os.Args[1] == "example" {
+ if len(os.Args) >= 3 {
+ name := os.Args[2]
+ entry, ok := examples.Registry[name]
+ if !ok {
+ fmt.Fprintf(os.Stderr, "Unknown example %q\n\n", name)
+ listExamples()
+ os.Exit(1)
+ }
+ slog.Info("running example", slog.String("name", name))
+ entry.Build(game, deps)
+ } else {
+ listExamples()
+ os.Exit(0)
+ }
+ } else {
+ examples.Registry["default"].Build(game, deps)
+ }
+
ebiten.SetRunnableOnUnfocused(true)
kcore.Expect(ebiten.RunGame(game), "failed to run game")
}