diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-06-06 20:59:12 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-06-06 20:59:12 +0200 |
| commit | d9a54f1319cc9dcc2861cecc9edfde14cf039c5b (patch) | |
| tree | 74df791d6eda0ffe6c09cb32e5ffd76c4849cbac | |
| parent | 18592fd9f520a33ab307e496b995c1a7bf284893 (diff) | |
Add examples catalog with subcommand dispatch
- Extract game engine from package main to engine/ library package
- Create examples/ package with registry, Entry type, and BuilderDeps
- Register default world as an example (moved from main())
- Add combat example: two teams of units facing each other
- Rewrite main.go as thin dispatch: go run . example [name]
- Update README with examples catalog one-liner
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | docs/examples-catalog.md | 253 | ||||
| -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.go | 180 | ||||
| -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.go | 42 | ||||
| -rw-r--r-- | examples/default.go | 40 | ||||
| -rw-r--r-- | examples/examples.go | 20 | ||||
| -rw-r--r-- | main.go | 227 |
18 files changed, 593 insertions, 199 deletions
@@ -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" @@ -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{} @@ -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") } |
