From d9a54f1319cc9dcc2861cecc9edfde14cf039c5b Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sat, 6 Jun 2026 20:59:12 +0200 Subject: 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 --- README.md | 2 + camera.go | 174 ------------------------------- docs/examples-catalog.md | 253 +++++++++++++++++++++++++++++++++++++++++++++ draw.go | 127 ----------------------- engine/camera.go | 174 +++++++++++++++++++++++++++++++ engine/draw.go | 127 +++++++++++++++++++++++ engine/entity.go | 107 +++++++++++++++++++ engine/game.go | 180 ++++++++++++++++++++++++++++++++ engine/gather_order.go | 80 ++++++++++++++ engine/image.go | 33 ++++++ engine/input.go | 36 +++++++ engine/integration_test.go | 201 +++++++++++++++++++++++++++++++++++ engine/order.go | 41 ++++++++ engine/patrol_order.go | 46 +++++++++ engine/resource.go | 20 ++++ engine/spawn.go | 53 ++++++++++ entity.go | 107 ------------------- examples/combat.go | 42 ++++++++ examples/default.go | 40 +++++++ examples/examples.go | 20 ++++ gather_order.go | 80 -------------- image.go | 33 ------ input.go | 36 ------- integration_test.go | 201 ----------------------------------- main.go | 227 ++++++++-------------------------------- order.go | 41 -------- patrol_order.go | 46 --------- resource.go | 20 ---- spawn.go | 53 ---------- 29 files changed, 1497 insertions(+), 1103 deletions(-) delete mode 100644 camera.go create mode 100644 docs/examples-catalog.md delete mode 100644 draw.go create mode 100644 engine/camera.go create mode 100644 engine/draw.go create mode 100644 engine/entity.go create mode 100644 engine/game.go create mode 100644 engine/gather_order.go create mode 100644 engine/image.go create mode 100644 engine/input.go create mode 100644 engine/integration_test.go create mode 100644 engine/order.go create mode 100644 engine/patrol_order.go create mode 100644 engine/resource.go create mode 100644 engine/spawn.go delete mode 100644 entity.go create mode 100644 examples/combat.go create mode 100644 examples/default.go create mode 100644 examples/examples.go delete mode 100644 gather_order.go delete mode 100644 image.go delete mode 100644 input.go delete mode 100644 integration_test.go delete mode 100644 order.go delete mode 100644 patrol_order.go delete mode 100644 resource.go delete mode 100644 spawn.go 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/camera.go b/camera.go deleted file mode 100644 index 9118117..0000000 --- a/camera.go +++ /dev/null @@ -1,174 +0,0 @@ -package main - -import ( - "math" - - "age_of_empires/physics" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/inpututil" -) - -const ( - ScreenWidth = 960 - ScreenHeight = 640 - WorldWidth = 3200 - WorldHeight = 2400 - - cameraZoomMin = 0.25 - cameraZoomMax = 3.0 - cameraZoomStep = 0.10 - cameraScrollSpeed = 400.0 - cameraDrag = 8.0 -) - -type Camera struct { - X, Y float64 - Zoom float64 - - vx, vy float64 - - dragActive bool - dragStartScreenX, dragStartScreenY int - dragStartCamX, dragStartCamY float64 -} - -func (c *Camera) WorldToScreen(p physics.Point) (float64, float64) { - sx := (float64(p.X)-c.X)*c.Zoom + ScreenWidth/2 - sy := (float64(p.Y)-c.Y)*c.Zoom + ScreenHeight/2 - return sx, sy -} - -func (c *Camera) ScreenToWorld(sx, sy int) physics.Point { - zoom := c.Zoom - if zoom == 0 { - zoom = 1 - } - wx := float64(sx-ScreenWidth/2)/zoom + c.X - wy := float64(sy-ScreenHeight/2)/zoom + c.Y - return physics.Point{X: int(math.Round(wx)), Y: int(math.Round(wy))} -} - -func (c *Camera) GeoM() ebiten.GeoM { - var m ebiten.GeoM - m.Translate(-c.X, -c.Y) - m.Scale(c.Zoom, c.Zoom) - m.Translate(ScreenWidth/2, ScreenHeight/2) - return m -} - -func (c *Camera) clampToBounds() { - halfW := (ScreenWidth / 2.0) / c.Zoom - halfH := (ScreenHeight / 2.0) / c.Zoom - - minX, maxX := halfW, float64(WorldWidth)-halfW - if minX > maxX { - minX = WorldWidth / 2 - maxX = WorldWidth / 2 - } - minY, maxY := halfH, float64(WorldHeight)-halfH - if minY > maxY { - minY = WorldHeight / 2 - maxY = WorldHeight / 2 - } - - if c.X < minX { - c.X = minX - if c.vx < 0 { - c.vx = 0 - } - } - if c.X > maxX { - c.X = maxX - if c.vx > 0 { - c.vx = 0 - } - } - if c.Y < minY { - c.Y = minY - if c.vy < 0 { - c.vy = 0 - } - } - if c.Y > maxY { - c.Y = maxY - if c.vy > 0 { - c.vy = 0 - } - } -} - -func (c *Camera) Update(dt float64) { - zoom := c.Zoom - if zoom == 0 { - zoom = 1 - } - - // Scroll wheel zoom toward cursor - _, wy := ebiten.Wheel() - if wy != 0 { - mx, my := ebiten.CursorPosition() - // cursor world pos before zoom - wx0 := float64(mx-ScreenWidth/2)/zoom + c.X - wy0 := float64(my-ScreenHeight/2)/zoom + c.Y - - newZoom := math.Max(cameraZoomMin, math.Min(cameraZoomMax, zoom+wy*cameraZoomStep)) - c.Zoom = newZoom - zoom = newZoom - - // translate so cursor stays fixed - wx1 := float64(mx-ScreenWidth/2)/zoom + c.X - wy1 := float64(my-ScreenHeight/2)/zoom + c.Y - c.X -= wx1 - wx0 - c.Y -= wy1 - wy0 - } - - // Middle-mouse drag - mx, my := ebiten.CursorPosition() - if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonMiddle) { - c.dragActive = true - c.dragStartScreenX = mx - c.dragStartScreenY = my - c.dragStartCamX = c.X - c.dragStartCamY = c.Y - } - if c.dragActive { - if ebiten.IsMouseButtonPressed(ebiten.MouseButtonMiddle) { - c.X = c.dragStartCamX - float64(mx-c.dragStartScreenX)/zoom - c.Y = c.dragStartCamY - float64(my-c.dragStartScreenY)/zoom - } else { - c.dragActive = false - } - } - - // Arrow key pan (velocity-based) - dx, dy := 0.0, 0.0 - if ebiten.IsKeyPressed(ebiten.KeyArrowLeft) { - dx -= 1 - } - if ebiten.IsKeyPressed(ebiten.KeyArrowRight) { - dx += 1 - } - if ebiten.IsKeyPressed(ebiten.KeyArrowUp) { - dy -= 1 - } - if ebiten.IsKeyPressed(ebiten.KeyArrowDown) { - dy += 1 - } - if dx != 0 && dy != 0 { - inv := 1.0 / math.Sqrt2 - dx *= inv - dy *= inv - } - c.vx += dx * cameraScrollSpeed - c.vy += dy * cameraScrollSpeed - - // Apply velocity and exponential drag - c.X += c.vx * dt - c.Y += c.vy * dt - decay := math.Exp(-cameraDrag * dt) - c.vx *= decay - c.vy *= decay - - c.clampToBounds() -} 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 ` +2. If `go run . example` (no name), print the registry and exit +3. If `go run . example `, 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/draw.go b/draw.go deleted file mode 100644 index e7f405f..0000000 --- a/draw.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "fmt" - "image/color" - - "age_of_empires/ecs" - "age_of_empires/physics" - "age_of_empires/selection" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/text/v2" - "github.com/hajimehoshi/ebiten/v2/vector" -) - -func (g *Game) font(size float64) *text.GoTextFace { - f := &text.GoTextFace{Source: g.FaceSource, Size: size} - f.SetVariation(text.MustParseTag("wght"), 500) - return f -} - -func (g *Game) Draw(screen *ebiten.Image) { - sx, sy := ebiten.CursorPosition() - cursor := g.Camera.ScreenToWorld(sx, sy) - screenCursor := physics.Point{X: sx, Y: sy} - screen.Fill(soilColor) - - camGeoM := g.Camera.GeoM() - - 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)) - op.GeoM.Concat(camGeoM) - screen.DrawImage(image, op) - if mask&ecs.CM_Selectable == ecs.CM_Selectable { - selectable := g.Selectable[i] - if selectable.IsSelected { - opt := &ebiten.DrawImageOptions{} - opt.GeoM.Translate(float64(position.X-selection.HaloWidth/2), float64(position.Y-selection.HaloWidth/2)) - opt.GeoM.Concat(camGeoM) - 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.GeoM.Concat(camGeoM) - op.ColorScale.ScaleWithColor(color.White) - text.Draw(screen, resourceText, g.font(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.GeoM.Concat(camGeoM) - op.ColorScale.ScaleWithColor(color.White) - text.Draw(screen, spawnText, g.font(40), op) - if spawn.SpawnTarget.Ok { - spawnTarget := spawn.SpawnTarget.Value - ssx, ssy := g.Camera.WorldToScreen(spawnTarget) - offset := 50 * float32(g.Camera.Zoom) - vector.DrawFilledCircle(screen, float32(ssx)+offset, float32(ssy)+offset, 30*float32(g.Camera.Zoom), 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 - strokeW := 10.0 * float32(g.Camera.Zoom) - for _, point := range move.Path { - lsx, lsy := g.Camera.WorldToScreen(physics.Point{X: last.X + dx, Y: last.Y + dy}) - psx, psy := g.Camera.WorldToScreen(physics.Point{X: point.X + dx, Y: point.Y + dy}) - vector.StrokeLine(screen, float32(lsx), float32(lsy), float32(psx), float32(psy), strokeW, 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, screenCursor) - } - - drawTopBanner(screen, g) - - if len(g.GlobalSelection.Selected) > 0 { - drawBottomBanner(screen, g) - } -} - -func drawTopBanner(screen *ebiten.Image, g *Game) { - screenBounds := screen.Bounds() - bannerHeight := float32(50) - vector.DrawFilledRect(screen, 0, 0, float32(screenBounds.Dx()), bannerHeight, color.White, true) - resourceText := fmt.Sprintf("Resources: %d", g.ResourceAmount) - op := &text.DrawOptions{} - op.GeoM.Translate(8, 6) - op.ColorScale.ScaleWithColor(color.Black) - text.Draw(screen, resourceText, g.font(30), op) -} - -func drawBottomBanner(screen *ebiten.Image, g *Game) { - screenBounds := screen.Bounds() - bannerHeight := float32(40) - bannerTop := screenBounds.Dy() - int(bannerHeight) - vector.DrawFilledRect(screen, 0, float32(bannerTop), float32(screenBounds.Dx()), bannerHeight, color.White, true) - - shortcuts := "[Right-click] Action [Q] Patrol [Esc] Deselect" - for _, i := range g.GlobalSelection.Selected { - if g.Mask[i]&ecs.CM_Spawn == ecs.CM_Spawn { - shortcuts += " [S] Spawn unit" - break - } - } - - op := &text.DrawOptions{} - op.GeoM.Translate(8, float64(float32(bannerTop)+6)) - op.ColorScale.ScaleWithColor(color.Black) - text.Draw(screen, shortcuts, g.font(22), op) -} diff --git a/engine/camera.go b/engine/camera.go new file mode 100644 index 0000000..867aaf4 --- /dev/null +++ b/engine/camera.go @@ -0,0 +1,174 @@ +package engine + +import ( + "math" + + "age_of_empires/physics" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" +) + +const ( + ScreenWidth = 960 + ScreenHeight = 640 + WorldWidth = 3200 + WorldHeight = 2400 + + cameraZoomMin = 0.25 + cameraZoomMax = 3.0 + cameraZoomStep = 0.10 + cameraScrollSpeed = 400.0 + cameraDrag = 8.0 +) + +type Camera struct { + X, Y float64 + Zoom float64 + + vx, vy float64 + + dragActive bool + dragStartScreenX, dragStartScreenY int + dragStartCamX, dragStartCamY float64 +} + +func (c *Camera) WorldToScreen(p physics.Point) (float64, float64) { + sx := (float64(p.X)-c.X)*c.Zoom + ScreenWidth/2 + sy := (float64(p.Y)-c.Y)*c.Zoom + ScreenHeight/2 + return sx, sy +} + +func (c *Camera) ScreenToWorld(sx, sy int) physics.Point { + zoom := c.Zoom + if zoom == 0 { + zoom = 1 + } + wx := float64(sx-ScreenWidth/2)/zoom + c.X + wy := float64(sy-ScreenHeight/2)/zoom + c.Y + return physics.Point{X: int(math.Round(wx)), Y: int(math.Round(wy))} +} + +func (c *Camera) GeoM() ebiten.GeoM { + var m ebiten.GeoM + m.Translate(-c.X, -c.Y) + m.Scale(c.Zoom, c.Zoom) + m.Translate(ScreenWidth/2, ScreenHeight/2) + return m +} + +func (c *Camera) clampToBounds() { + halfW := (ScreenWidth / 2.0) / c.Zoom + halfH := (ScreenHeight / 2.0) / c.Zoom + + minX, maxX := halfW, float64(WorldWidth)-halfW + if minX > maxX { + minX = WorldWidth / 2 + maxX = WorldWidth / 2 + } + minY, maxY := halfH, float64(WorldHeight)-halfH + if minY > maxY { + minY = WorldHeight / 2 + maxY = WorldHeight / 2 + } + + if c.X < minX { + c.X = minX + if c.vx < 0 { + c.vx = 0 + } + } + if c.X > maxX { + c.X = maxX + if c.vx > 0 { + c.vx = 0 + } + } + if c.Y < minY { + c.Y = minY + if c.vy < 0 { + c.vy = 0 + } + } + if c.Y > maxY { + c.Y = maxY + if c.vy > 0 { + c.vy = 0 + } + } +} + +func (c *Camera) Update(dt float64) { + zoom := c.Zoom + if zoom == 0 { + zoom = 1 + } + + // Scroll wheel zoom toward cursor + _, wy := ebiten.Wheel() + if wy != 0 { + mx, my := ebiten.CursorPosition() + // cursor world pos before zoom + wx0 := float64(mx-ScreenWidth/2)/zoom + c.X + wy0 := float64(my-ScreenHeight/2)/zoom + c.Y + + newZoom := math.Max(cameraZoomMin, math.Min(cameraZoomMax, zoom+wy*cameraZoomStep)) + c.Zoom = newZoom + zoom = newZoom + + // translate so cursor stays fixed + wx1 := float64(mx-ScreenWidth/2)/zoom + c.X + wy1 := float64(my-ScreenHeight/2)/zoom + c.Y + c.X -= wx1 - wx0 + c.Y -= wy1 - wy0 + } + + // Middle-mouse drag + mx, my := ebiten.CursorPosition() + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonMiddle) { + c.dragActive = true + c.dragStartScreenX = mx + c.dragStartScreenY = my + c.dragStartCamX = c.X + c.dragStartCamY = c.Y + } + if c.dragActive { + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonMiddle) { + c.X = c.dragStartCamX - float64(mx-c.dragStartScreenX)/zoom + c.Y = c.dragStartCamY - float64(my-c.dragStartScreenY)/zoom + } else { + c.dragActive = false + } + } + + // Arrow key pan (velocity-based) + dx, dy := 0.0, 0.0 + if ebiten.IsKeyPressed(ebiten.KeyArrowLeft) { + dx -= 1 + } + if ebiten.IsKeyPressed(ebiten.KeyArrowRight) { + dx += 1 + } + if ebiten.IsKeyPressed(ebiten.KeyArrowUp) { + dy -= 1 + } + if ebiten.IsKeyPressed(ebiten.KeyArrowDown) { + dy += 1 + } + if dx != 0 && dy != 0 { + inv := 1.0 / math.Sqrt2 + dx *= inv + dy *= inv + } + c.vx += dx * cameraScrollSpeed + c.vy += dy * cameraScrollSpeed + + // Apply velocity and exponential drag + c.X += c.vx * dt + c.Y += c.vy * dt + decay := math.Exp(-cameraDrag * dt) + c.vx *= decay + c.vy *= decay + + c.clampToBounds() +} diff --git a/engine/draw.go b/engine/draw.go new file mode 100644 index 0000000..ca605ce --- /dev/null +++ b/engine/draw.go @@ -0,0 +1,127 @@ +package engine + +import ( + "fmt" + "image/color" + + "age_of_empires/ecs" + "age_of_empires/physics" + "age_of_empires/selection" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/text/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +func (g *Game) font(size float64) *text.GoTextFace { + f := &text.GoTextFace{Source: g.FaceSource, Size: size} + f.SetVariation(text.MustParseTag("wght"), 500) + return f +} + +func (g *Game) Draw(screen *ebiten.Image) { + sx, sy := ebiten.CursorPosition() + cursor := g.Camera.ScreenToWorld(sx, sy) + screenCursor := physics.Point{X: sx, Y: sy} + screen.Fill(soilColor) + + camGeoM := g.Camera.GeoM() + + 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)) + op.GeoM.Concat(camGeoM) + screen.DrawImage(image, op) + if mask&ecs.CM_Selectable == ecs.CM_Selectable { + selectable := g.Selectable[i] + if selectable.IsSelected { + opt := &ebiten.DrawImageOptions{} + opt.GeoM.Translate(float64(position.X-selection.HaloWidth/2), float64(position.Y-selection.HaloWidth/2)) + opt.GeoM.Concat(camGeoM) + 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.GeoM.Concat(camGeoM) + op.ColorScale.ScaleWithColor(color.White) + text.Draw(screen, resourceText, g.font(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.GeoM.Concat(camGeoM) + op.ColorScale.ScaleWithColor(color.White) + text.Draw(screen, spawnText, g.font(40), op) + if spawn.SpawnTarget.Ok { + spawnTarget := spawn.SpawnTarget.Value + ssx, ssy := g.Camera.WorldToScreen(spawnTarget) + offset := 50 * float32(g.Camera.Zoom) + vector.DrawFilledCircle(screen, float32(ssx)+offset, float32(ssy)+offset, 30*float32(g.Camera.Zoom), 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 + strokeW := 10.0 * float32(g.Camera.Zoom) + for _, point := range move.Path { + lsx, lsy := g.Camera.WorldToScreen(physics.Point{X: last.X + dx, Y: last.Y + dy}) + psx, psy := g.Camera.WorldToScreen(physics.Point{X: point.X + dx, Y: point.Y + dy}) + vector.StrokeLine(screen, float32(lsx), float32(lsy), float32(psx), float32(psy), strokeW, 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, screenCursor) + } + + drawTopBanner(screen, g) + + if len(g.GlobalSelection.Selected) > 0 { + drawBottomBanner(screen, g) + } +} + +func drawTopBanner(screen *ebiten.Image, g *Game) { + screenBounds := screen.Bounds() + bannerHeight := float32(50) + vector.DrawFilledRect(screen, 0, 0, float32(screenBounds.Dx()), bannerHeight, color.White, true) + resourceText := fmt.Sprintf("Resources: %d", g.ResourceAmount) + op := &text.DrawOptions{} + op.GeoM.Translate(8, 6) + op.ColorScale.ScaleWithColor(color.Black) + text.Draw(screen, resourceText, g.font(30), op) +} + +func drawBottomBanner(screen *ebiten.Image, g *Game) { + screenBounds := screen.Bounds() + bannerHeight := float32(40) + bannerTop := screenBounds.Dy() - int(bannerHeight) + vector.DrawFilledRect(screen, 0, float32(bannerTop), float32(screenBounds.Dx()), bannerHeight, color.White, true) + + shortcuts := "[Right-click] Action [Q] Patrol [Esc] Deselect" + for _, i := range g.GlobalSelection.Selected { + if g.Mask[i]&ecs.CM_Spawn == ecs.CM_Spawn { + shortcuts += " [S] Spawn unit" + break + } + } + + op := &text.DrawOptions{} + op.GeoM.Translate(8, float64(float32(bannerTop)+6)) + op.ColorScale.ScaleWithColor(color.Black) + text.Draw(screen, shortcuts, g.font(22), op) +} diff --git a/engine/entity.go b/engine/entity.go new file mode 100644 index 0000000..7aa43b1 --- /dev/null +++ b/engine/entity.go @@ -0,0 +1,107 @@ +package engine + +import ( + "image/color" + + "age_of_empires/ecs" + "age_of_empires/physics" + "age_of_empires/selection" + + "github.com/hajimehoshi/ebiten/v2" +) + +type Components struct { + Position physics.Point + RelBounds physics.Rectangle + Image *ebiten.Image + Selectable selection.Selectable + Move physics.Move + Order OrderKind + ResourceGatherer ResourceGatherer + ResourceSource ResourceSource + ResourceStorage ResourceStorage + Spawn Spawn +} + +type EntityBuilder struct { + mask ecs.Mask + components Components +} + +func NewEntityBuilder() EntityBuilder { + return EntityBuilder{ + components: Components{}, + } +} + +func (b EntityBuilder) WithPosition(position physics.Point) EntityBuilder { + b.components.Position = position + b.mask |= ecs.CM_Position + return b +} + +func (b EntityBuilder) WithSolid(image *ebiten.Image) EntityBuilder { + 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) WithSelectable(haloKind string, priority selection.Priority) EntityBuilder { + b.components.Selectable = selection.Selectable{ + IsSelected: false, + Halo: nil, + Priority: priority, + } + b.mask |= ecs.CM_Selectable + switch haloKind { + case "round": + b.components.Selectable.Halo = NewStrokeCircleImage(110, selection.HaloWidth, Red) + case "square": + b.components.Selectable.Halo = NewStrokeRectangleImage(physics.Point{X: 110, Y: 110}, selection.HaloWidth, Red) + } + return b +} + +func (b EntityBuilder) WithResourceStorage() EntityBuilder { + b.components.ResourceStorage = ResourceStorage{} + b.mask |= ecs.CM_ResourceStorage + return b +} + +func (b EntityBuilder) WithResourceSource(amount int) EntityBuilder { + b.components.ResourceSource = ResourceSource{Remaining: amount} + b.mask |= ecs.CM_ResourceSource + return b +} + +func (b EntityBuilder) WithSpawn(spawn Spawn) EntityBuilder { + b.components.Spawn = spawn + b.mask |= ecs.CM_Spawn + return b +} + +func (b EntityBuilder) WithMove() EntityBuilder { + b.components.Move = physics.Move{IsActive: false} + b.mask |= ecs.CM_Move + return b +} + +func (b EntityBuilder) WithOrder() EntityBuilder { + b.components.Order = OrderKindNone + b.mask |= ecs.CM_Order + return b +} + +func (b EntityBuilder) WithResourceGatherer(maxCapacity int) EntityBuilder { + b.components.ResourceGatherer = ResourceGatherer{MaxCapacity: maxCapacity} + b.mask |= ecs.CM_ResourceGatherer + return b +} + +func (b EntityBuilder) Build() (Components, ecs.Mask) { + return b.components, b.mask +} 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/engine/gather_order.go b/engine/gather_order.go new file mode 100644 index 0000000..e0c71fb --- /dev/null +++ b/engine/gather_order.go @@ -0,0 +1,80 @@ +package engine + +import ( + "math" + "time" + + "age_of_empires/ecs" + "age_of_empires/physics" + + "golang.org/x/exp/slog" +) + +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 updateGathering(now time.Time, gatherer *ResourceGatherer, source *ResourceSource) { + if gatherer.CurrentVolume >= gatherer.MaxCapacity || gatherer.LastPickupTime.Add(200*time.Millisecond).After(now) { + return + } + gatherer.LastPickupTime = now + gatherer.CurrentVolume += 1 + source.Remaining -= 1 +} + +func updateGathererFull(g *Game, storageDockings []physics.Point, moveMap physics.MoveMap, gatherer *ResourceGatherer, order *OrderKind, move *physics.Move, position physics.Point) { + if move.IsActive { + return + } + destination, distance := physics.Closest(position, storageDockings, moveMap) + if distance == math.MaxInt { + slog.Info("no accessible storage, canceling gather order") + *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())) + *move = physics.NewMove(position, destination, moveMap) + return + } + g.ResourceAmount += gatherer.CurrentVolume + gatherer.CurrentVolume = 0 + startMoveToSource(moveMap, gatherer, move, order, position, g.Position[gatherer.Source]) +} + +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") + *order = OrderKindNone + return + } + *move = physics.NewMove(position, destination, moveMap) +} + +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/engine/image.go b/engine/image.go new file mode 100644 index 0000000..07cac55 --- /dev/null +++ b/engine/image.go @@ -0,0 +1,33 @@ +package engine + +import ( + "age_of_empires/physics" + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +func NewFilledRectangleImage(size physics.Point, color color.Color) *ebiten.Image { + image := ebiten.NewImage(size.X, size.Y) + image.Fill(color) + return image +} + +func NewStrokeRectangleImage(size physics.Point, strokeWidth float32, color color.Color) *ebiten.Image { + image := ebiten.NewImage(size.X, size.Y) + vector.StrokeRect(image, 0, 0, float32(size.X), float32(size.Y), float32(strokeWidth), color, true) + return image +} + +func NewFilledCircleImage(width int, color color.Color) *ebiten.Image { + image := ebiten.NewImage((width), (width)) + vector.DrawFilledCircle(image, float32(width/2), float32(width/2), float32(width/2), color, true) + return image +} + +func NewStrokeCircleImage(width int, strokeWidth int, color color.Color) *ebiten.Image { + image := ebiten.NewImage((width), (width)) + vector.StrokeCircle(image, float32(width/2), float32(width/2), float32(width/2), float32(strokeWidth), color, true) + return image +} diff --git a/engine/input.go b/engine/input.go new file mode 100644 index 0000000..5d03b08 --- /dev/null +++ b/engine/input.go @@ -0,0 +1,36 @@ +package engine + +import ( + "age_of_empires/physics" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" +) + +type Input struct { + Cursor physics.Point + ScreenCursor physics.Point + + EscapePressed bool + PatrolPressed bool + + LeftMouseDown bool + LeftMouseUp bool + RightMouseUp bool + + SpawnPressed bool +} + +func CollectInput(camera *Camera) Input { + sx, sy := ebiten.CursorPosition() + return Input{ + Cursor: camera.ScreenToWorld(sx, sy), + ScreenCursor: physics.Point{X: sx, Y: sy}, + EscapePressed: inpututil.IsKeyJustReleased(ebiten.KeyEscape), + PatrolPressed: inpututil.IsKeyJustReleased(KeyPatrolRequest), + LeftMouseDown: inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft), + LeftMouseUp: inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft), + RightMouseUp: inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight), + SpawnPressed: inpututil.IsKeyJustReleased(KeySpawnRequest), + } +} diff --git a/engine/integration_test.go b/engine/integration_test.go new file mode 100644 index 0000000..b999b45 --- /dev/null +++ b/engine/integration_test.go @@ -0,0 +1,201 @@ +package engine + +import ( + "image/color" + "testing" + "time" + + "age_of_empires/physics" + "age_of_empires/selection" + + "github.com/stretchr/testify/assert" +) + +func TestCameraScreenToWorldRoundTrip(t *testing.T) { + zooms := []float64{0.25, 0.5, 1.0, 2.0, 3.0} + points := []physics.Point{ + {X: 1000, Y: 1000}, + {X: 0, Y: 0}, + {X: 3200, Y: 2400}, + {X: 1600, Y: 1200}, + } + for _, zoom := range zooms { + cam := Camera{X: 1600, Y: 1200, Zoom: zoom} + for _, p := range points { + sx, sy := cam.WorldToScreen(p) + got := cam.ScreenToWorld(int(sx), int(sy)) + assert.Equal(t, p, got, "zoom=%.2f point=%v", zoom, p) + } + } +} + +func TestCameraClampsToBounds(t *testing.T) { + cases := []struct{ x, y float64 }{ + {-99999, -99999}, + {99999, 99999}, + {-99999, 99999}, + {99999, -99999}, + } + for _, c := range cases { + cam := Camera{X: c.x, Y: c.y, Zoom: 1.0} + cam.clampToBounds() + halfW := (ScreenWidth / 2.0) / cam.Zoom + halfH := (ScreenHeight / 2.0) / cam.Zoom + assert.GreaterOrEqual(t, cam.X, halfW, "X=%v should be >= halfW", c.x) + assert.LessOrEqual(t, cam.X, float64(WorldWidth)-halfW, "X=%v should be <= maxX", c.x) + assert.GreaterOrEqual(t, cam.Y, halfH, "Y=%v should be >= halfH", c.y) + assert.LessOrEqual(t, cam.Y, float64(WorldHeight)-halfH, "Y=%v should be <= maxY", c.y) + } +} + +func TestCameraZoomTowardCursor(t *testing.T) { + zooms := []struct{ before, after float64 }{ + {1.0, 2.0}, + {1.0, 0.5}, + {2.0, 3.0}, + } + cursorSX, cursorSY := 400, 300 + for _, z := range zooms { + cam := Camera{X: 1600, Y: 1200, Zoom: z.before} + worldBefore := cam.ScreenToWorld(cursorSX, cursorSY) + + // replicate the zoom-toward-cursor logic from Camera.Update + wx0 := float64(cursorSX-ScreenWidth/2)/cam.Zoom + cam.X + wy0 := float64(cursorSY-ScreenHeight/2)/cam.Zoom + cam.Y + cam.Zoom = z.after + wx1 := float64(cursorSX-ScreenWidth/2)/cam.Zoom + cam.X + wy1 := float64(cursorSY-ScreenHeight/2)/cam.Zoom + cam.Y + cam.X -= wx1 - wx0 + cam.Y -= wy1 - wy0 + + worldAfter := cam.ScreenToWorld(cursorSX, cursorSY) + assert.Equal(t, worldBefore, worldAfter, "zoom %.1f→%.1f: world point under cursor should be stable", z.before, z.after) + } +} + +func TestGatherLoop(t *testing.T) { + // Initialize Game + game := &Game{} + + // Town Center (Storage) at -100,0 (different grid cell from unit at 0,0) + tcBuilder := NewEntityBuilder(). + WithPosition(physics.Point{X: -100, Y: 0}). + WithResourceStorage() + _ = game.Append(tcBuilder.Build()) + + // Mine (Source) at 0, 200 + mineBuilder := NewEntityBuilder(). + WithPosition(physics.Point{X: 0, Y: 200}). + WithResourceSource(100) + mineID := game.Append(mineBuilder.Build()) + + // Unit (Gatherer) at 0, 0 + unitBuilder := NewEntityBuilder(). + WithPosition(physics.Point{X: 0, Y: 0}). + WithSolid(NewFilledCircleImage(100, color.White)). + WithSelectable("round", selection.Unit). + WithMove(). + WithOrder(). + WithResourceGatherer(10) + game.Append(unitBuilder.Build()) + + // Set up simulated time + simTime := time.Now() + game.NowFunc = func() time.Time { return simTime } + + // Click unit to select, then right-click mine — same gesture as in-game + game.UpdateSimulation(Input{EscapePressed: true}) + game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: 50}, LeftMouseDown: true}) + game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: 50}, LeftMouseUp: true}) + game.UpdateSimulation(Input{Cursor: game.Position[mineID], RightMouseUp: true}) + + initialResources := game.ResourceAmount + dt := 16 * time.Millisecond + maxTicks := 100_000 + + deposited := false + + for i := 0; i < maxTicks; i++ { + simTime = simTime.Add(dt) + game.UpdateSimulation(Input{}) + + if game.ResourceAmount > initialResources { + deposited = true + break + } + } + + if !deposited { + t.Errorf("Unit failed to gather resources within %d ticks. ResourceAmount: %d", maxTicks, game.ResourceAmount) + } +} + +func TestTwoEntitiesMovingToSameCellDontOverlap(t *testing.T) { + // Two units close together both ordered to the same cell should not overlap. + game := &Game{} + + target := physics.Point{X: 500, Y: 0} + unitImg := NewFilledCircleImage(100, color.White) + + unitA := game.Append(NewEntityBuilder(). + WithPosition(physics.Point{X: 200, Y: 0}). + WithSolid(unitImg).WithSelectable("round", selection.Unit). + WithMove().WithOrder().Build()) + unitB := game.Append(NewEntityBuilder(). + WithPosition(physics.Point{X: 100, Y: 0}). + WithSolid(unitImg).WithSelectable("round", selection.Unit). + WithMove().WithOrder().Build()) + + // Area-select both units then right-click target — same gesture as in-game + game.UpdateSimulation(Input{EscapePressed: true}) + game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: -50}, LeftMouseDown: true}) + game.UpdateSimulation(Input{Cursor: physics.Point{X: 350, Y: 50}, LeftMouseUp: true}) + game.UpdateSimulation(Input{Cursor: target, RightMouseUp: true}) + + // MOVE_SPEED=10, cell=100 → 10 ticks per cell. B detours around A → ~60 ticks. + for range 60 { + game.UpdateSimulation(Input{}) + } + + assert.False(t, game.Move[unitA].IsActive, "Entity A should have stopped moving") + assert.False(t, game.Move[unitB].IsActive, "Entity B should have stopped moving") + assert.NotEqual(t, game.Position[unitA], game.Position[unitB], + "Entity A and B should NOT overlap: A at %s, B at %s", game.Position[unitA], game.Position[unitB]) +} + +func TestUnitDoesNotStopWhenDestinationOccupied(t *testing.T) { + // Unit B is far away, unit A is close. Both ordered to the same cell. + // A arrives first and settles. B should repath to an adjacent free cell + // rather than stopping mid-journey. + game := &Game{} + game.CurrentAction = Selecting + unitImg := NewFilledCircleImage(100, color.White) + + target := physics.Point{X: 500, Y: 0} + + unitA := game.Append(NewEntityBuilder(). + WithPosition(physics.Point{X: 400, Y: 0}). + WithSolid(unitImg).WithSelectable("round", selection.Unit). + WithMove().WithOrder().Build()) + unitB := game.Append(NewEntityBuilder(). + WithPosition(physics.Point{X: 0, Y: 0}). + WithSolid(unitImg).WithSelectable("round", selection.Unit). + WithMove().WithOrder().Build()) + + game.UpdateSimulation(Input{EscapePressed: true}) + game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: -50}, LeftMouseDown: true}) + game.UpdateSimulation(Input{Cursor: physics.Point{X: 450, Y: 50}, LeftMouseUp: true}) + game.UpdateSimulation(Input{Cursor: target, RightMouseUp: true}) + + // Run long enough for both units to settle (B travels ~5 cells) + for range 100 { + game.UpdateSimulation(Input{}) + } + + assert.False(t, game.Move[unitA].IsActive, "unit A should have stopped") + assert.False(t, game.Move[unitB].IsActive, "unit B should have stopped near target, not mid-journey") + assert.NotEqual(t, game.Position[unitA], game.Position[unitB], "units should not overlap") + // B should be adjacent to target, not stuck far away + assert.LessOrEqual(t, physics.Distance(game.Position[unitB], target), 150.0, + "unit B should be adjacent to target, got %s", game.Position[unitB]) +} diff --git a/engine/order.go b/engine/order.go new file mode 100644 index 0000000..8aa991b --- /dev/null +++ b/engine/order.go @@ -0,0 +1,41 @@ +package engine + +import ( + "log/slog" + + "age_of_empires/ecs" + "age_of_empires/monad" + "age_of_empires/physics" +) + +type OrderKind int + +const ( + OrderKindNone OrderKind = iota + OrderKindPatrol + OrderKindGather +) + +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 + } + 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 mask&ecs.CM_Spawn == ecs.CM_Spawn { + slog.Info("Setting spawn target") + spawn := &g.Spawn[mainEntity] + spawn.SpawnTarget = monad.Some(target) + return + } +} diff --git a/engine/patrol_order.go b/engine/patrol_order.go new file mode 100644 index 0000000..0889f92 --- /dev/null +++ b/engine/patrol_order.go @@ -0,0 +1,46 @@ +package engine + +import ( + "age_of_empires/ecs" + "age_of_empires/physics" + + "golang.org/x/exp/slog" +) + +type PatrolOrder struct { + origin physics.Point + destination physics.Point +} + +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 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 + } + } +} diff --git a/engine/resource.go b/engine/resource.go new file mode 100644 index 0000000..406750c --- /dev/null +++ b/engine/resource.go @@ -0,0 +1,20 @@ +package engine + +import ( + "time" + + "age_of_empires/ecs" +) + +type ResourceSource struct { + Remaining int +} + +type ResourceGatherer struct { + MaxCapacity int + CurrentVolume int + Source ecs.Entity + LastPickupTime time.Time +} + +type ResourceStorage struct{} diff --git a/engine/spawn.go b/engine/spawn.go new file mode 100644 index 0000000..fc32a84 --- /dev/null +++ b/engine/spawn.go @@ -0,0 +1,53 @@ +package engine + +import ( + "log/slog" + "time" + + "age_of_empires/ecs" + "age_of_empires/monad" + "age_of_empires/physics" +) + +type SpawnRequest struct { + Start time.Time +} + +type Spawn struct { + UnitResourceCost int + UnitSpawnDuration time.Duration + 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), + } +} + +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 + } + } + } +} diff --git a/entity.go b/entity.go deleted file mode 100644 index 40ca0ea..0000000 --- a/entity.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "image/color" - - "age_of_empires/ecs" - "age_of_empires/physics" - "age_of_empires/selection" - - "github.com/hajimehoshi/ebiten/v2" -) - -type Components struct { - Position physics.Point - RelBounds physics.Rectangle - Image *ebiten.Image - Selectable selection.Selectable - Move physics.Move - Order OrderKind - ResourceGatherer ResourceGatherer - ResourceSource ResourceSource - ResourceStorage ResourceStorage - Spawn Spawn -} - -type EntityBuilder struct { - mask ecs.Mask - components Components -} - -func NewEntityBuilder() EntityBuilder { - return EntityBuilder{ - components: Components{}, - } -} - -func (b EntityBuilder) WithPosition(position physics.Point) EntityBuilder { - b.components.Position = position - b.mask |= ecs.CM_Position - return b -} - -func (b EntityBuilder) WithSolid(image *ebiten.Image) EntityBuilder { - 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) WithSelectable(haloKind string, priority selection.Priority) EntityBuilder { - b.components.Selectable = selection.Selectable{ - IsSelected: false, - Halo: nil, - Priority: priority, - } - b.mask |= ecs.CM_Selectable - switch haloKind { - case "round": - b.components.Selectable.Halo = NewStrokeCircleImage(110, selection.HaloWidth, Red) - case "square": - b.components.Selectable.Halo = NewStrokeRectangleImage(physics.Point{X: 110, Y: 110}, selection.HaloWidth, Red) - } - return b -} - -func (b EntityBuilder) WithResourceStorage() EntityBuilder { - b.components.ResourceStorage = ResourceStorage{} - b.mask |= ecs.CM_ResourceStorage - return b -} - -func (b EntityBuilder) WithResourceSource(amount int) EntityBuilder { - b.components.ResourceSource = ResourceSource{Remaining: amount} - b.mask |= ecs.CM_ResourceSource - return b -} - -func (b EntityBuilder) WithSpawn(spawn Spawn) EntityBuilder { - b.components.Spawn = spawn - b.mask |= ecs.CM_Spawn - return b -} - -func (b EntityBuilder) WithMove() EntityBuilder { - b.components.Move = physics.Move{IsActive: false} - b.mask |= ecs.CM_Move - return b -} - -func (b EntityBuilder) WithOrder() EntityBuilder { - b.components.Order = OrderKindNone - b.mask |= ecs.CM_Order - return b -} - -func (b EntityBuilder) WithResourceGatherer(maxCapacity int) EntityBuilder { - b.components.ResourceGatherer = ResourceGatherer{MaxCapacity: maxCapacity} - b.mask |= ecs.CM_ResourceGatherer - return b -} - -func (b EntityBuilder) Build() (Components, ecs.Mask) { - return b.components, b.mask -} 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/gather_order.go b/gather_order.go deleted file mode 100644 index fd56fbe..0000000 --- a/gather_order.go +++ /dev/null @@ -1,80 +0,0 @@ -package main - -import ( - "math" - "time" - - "age_of_empires/ecs" - "age_of_empires/physics" - - "golang.org/x/exp/slog" -) - -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 updateGathering(now time.Time, gatherer *ResourceGatherer, source *ResourceSource) { - if gatherer.CurrentVolume >= gatherer.MaxCapacity || gatherer.LastPickupTime.Add(200*time.Millisecond).After(now) { - return - } - gatherer.LastPickupTime = now - gatherer.CurrentVolume += 1 - source.Remaining -= 1 -} - -func updateGathererFull(g *Game, storageDockings []physics.Point, moveMap physics.MoveMap, gatherer *ResourceGatherer, order *OrderKind, move *physics.Move, position physics.Point) { - if move.IsActive { - return - } - destination, distance := physics.Closest(position, storageDockings, moveMap) - if distance == math.MaxInt { - slog.Info("no accessible storage, canceling gather order") - *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())) - *move = physics.NewMove(position, destination, moveMap) - return - } - g.ResourceAmount += gatherer.CurrentVolume - gatherer.CurrentVolume = 0 - startMoveToSource(moveMap, gatherer, move, order, position, g.Position[gatherer.Source]) -} - -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") - *order = OrderKindNone - return - } - *move = physics.NewMove(position, destination, moveMap) -} - -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/image.go b/image.go deleted file mode 100644 index 50f1a19..0000000 --- a/image.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "age_of_empires/physics" - "image/color" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/vector" -) - -func NewFilledRectangleImage(size physics.Point, color color.Color) *ebiten.Image { - image := ebiten.NewImage(size.X, size.Y) - image.Fill(color) - return image -} - -func NewStrokeRectangleImage(size physics.Point, strokeWidth float32, color color.Color) *ebiten.Image { - image := ebiten.NewImage(size.X, size.Y) - vector.StrokeRect(image, 0, 0, float32(size.X), float32(size.Y), float32(strokeWidth), color, true) - return image -} - -func NewFilledCircleImage(width int, color color.Color) *ebiten.Image { - image := ebiten.NewImage((width), (width)) - vector.DrawFilledCircle(image, float32(width/2), float32(width/2), float32(width/2), color, true) - return image -} - -func NewStrokeCircleImage(width int, strokeWidth int, color color.Color) *ebiten.Image { - image := ebiten.NewImage((width), (width)) - vector.StrokeCircle(image, float32(width/2), float32(width/2), float32(width/2), float32(strokeWidth), color, true) - return image -} diff --git a/input.go b/input.go deleted file mode 100644 index 5b9be32..0000000 --- a/input.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "age_of_empires/physics" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/inpututil" -) - -type Input struct { - Cursor physics.Point - ScreenCursor physics.Point - - EscapePressed bool - PatrolPressed bool - - LeftMouseDown bool - LeftMouseUp bool - RightMouseUp bool - - SpawnPressed bool -} - -func CollectInput(camera *Camera) Input { - sx, sy := ebiten.CursorPosition() - return Input{ - Cursor: camera.ScreenToWorld(sx, sy), - ScreenCursor: physics.Point{X: sx, Y: sy}, - EscapePressed: inpututil.IsKeyJustReleased(ebiten.KeyEscape), - PatrolPressed: inpututil.IsKeyJustReleased(KeyPatrolRequest), - LeftMouseDown: inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft), - LeftMouseUp: inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft), - RightMouseUp: inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight), - SpawnPressed: inpututil.IsKeyJustReleased(KeySpawnRequest), - } -} diff --git a/integration_test.go b/integration_test.go deleted file mode 100644 index 7d687fb..0000000 --- a/integration_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package main - -import ( - "image/color" - "testing" - "time" - - "age_of_empires/physics" - "age_of_empires/selection" - - "github.com/stretchr/testify/assert" -) - -func TestCameraScreenToWorldRoundTrip(t *testing.T) { - zooms := []float64{0.25, 0.5, 1.0, 2.0, 3.0} - points := []physics.Point{ - {X: 1000, Y: 1000}, - {X: 0, Y: 0}, - {X: 3200, Y: 2400}, - {X: 1600, Y: 1200}, - } - for _, zoom := range zooms { - cam := Camera{X: 1600, Y: 1200, Zoom: zoom} - for _, p := range points { - sx, sy := cam.WorldToScreen(p) - got := cam.ScreenToWorld(int(sx), int(sy)) - assert.Equal(t, p, got, "zoom=%.2f point=%v", zoom, p) - } - } -} - -func TestCameraClampsToBounds(t *testing.T) { - cases := []struct{ x, y float64 }{ - {-99999, -99999}, - {99999, 99999}, - {-99999, 99999}, - {99999, -99999}, - } - for _, c := range cases { - cam := Camera{X: c.x, Y: c.y, Zoom: 1.0} - cam.clampToBounds() - halfW := (ScreenWidth / 2.0) / cam.Zoom - halfH := (ScreenHeight / 2.0) / cam.Zoom - assert.GreaterOrEqual(t, cam.X, halfW, "X=%v should be >= halfW", c.x) - assert.LessOrEqual(t, cam.X, float64(WorldWidth)-halfW, "X=%v should be <= maxX", c.x) - assert.GreaterOrEqual(t, cam.Y, halfH, "Y=%v should be >= halfH", c.y) - assert.LessOrEqual(t, cam.Y, float64(WorldHeight)-halfH, "Y=%v should be <= maxY", c.y) - } -} - -func TestCameraZoomTowardCursor(t *testing.T) { - zooms := []struct{ before, after float64 }{ - {1.0, 2.0}, - {1.0, 0.5}, - {2.0, 3.0}, - } - cursorSX, cursorSY := 400, 300 - for _, z := range zooms { - cam := Camera{X: 1600, Y: 1200, Zoom: z.before} - worldBefore := cam.ScreenToWorld(cursorSX, cursorSY) - - // replicate the zoom-toward-cursor logic from Camera.Update - wx0 := float64(cursorSX-ScreenWidth/2)/cam.Zoom + cam.X - wy0 := float64(cursorSY-ScreenHeight/2)/cam.Zoom + cam.Y - cam.Zoom = z.after - wx1 := float64(cursorSX-ScreenWidth/2)/cam.Zoom + cam.X - wy1 := float64(cursorSY-ScreenHeight/2)/cam.Zoom + cam.Y - cam.X -= wx1 - wx0 - cam.Y -= wy1 - wy0 - - worldAfter := cam.ScreenToWorld(cursorSX, cursorSY) - assert.Equal(t, worldBefore, worldAfter, "zoom %.1f→%.1f: world point under cursor should be stable", z.before, z.after) - } -} - -func TestGatherLoop(t *testing.T) { - // Initialize Game - game := &Game{} - - // Town Center (Storage) at -100,0 (different grid cell from unit at 0,0) - tcBuilder := NewEntityBuilder(). - WithPosition(physics.Point{X: -100, Y: 0}). - WithResourceStorage() - _ = game.Append(tcBuilder.Build()) - - // Mine (Source) at 0, 200 - mineBuilder := NewEntityBuilder(). - WithPosition(physics.Point{X: 0, Y: 200}). - WithResourceSource(100) - mineID := game.Append(mineBuilder.Build()) - - // Unit (Gatherer) at 0, 0 - unitBuilder := NewEntityBuilder(). - WithPosition(physics.Point{X: 0, Y: 0}). - WithSolid(NewFilledCircleImage(100, color.White)). - WithSelectable("round", selection.Unit). - WithMove(). - WithOrder(). - WithResourceGatherer(10) - game.Append(unitBuilder.Build()) - - // Set up simulated time - simTime := time.Now() - game.NowFunc = func() time.Time { return simTime } - - // Click unit to select, then right-click mine — same gesture as in-game - game.UpdateSimulation(Input{EscapePressed: true}) - game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: 50}, LeftMouseDown: true}) - game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: 50}, LeftMouseUp: true}) - game.UpdateSimulation(Input{Cursor: game.Position[mineID], RightMouseUp: true}) - - initialResources := game.ResourceAmount - dt := 16 * time.Millisecond - maxTicks := 100_000 - - deposited := false - - for i := 0; i < maxTicks; i++ { - simTime = simTime.Add(dt) - game.UpdateSimulation(Input{}) - - if game.ResourceAmount > initialResources { - deposited = true - break - } - } - - if !deposited { - t.Errorf("Unit failed to gather resources within %d ticks. ResourceAmount: %d", maxTicks, game.ResourceAmount) - } -} - -func TestTwoEntitiesMovingToSameCellDontOverlap(t *testing.T) { - // Two units close together both ordered to the same cell should not overlap. - game := &Game{} - - target := physics.Point{X: 500, Y: 0} - unitImg := NewFilledCircleImage(100, color.White) - - unitA := game.Append(NewEntityBuilder(). - WithPosition(physics.Point{X: 200, Y: 0}). - WithSolid(unitImg).WithSelectable("round", selection.Unit). - WithMove().WithOrder().Build()) - unitB := game.Append(NewEntityBuilder(). - WithPosition(physics.Point{X: 100, Y: 0}). - WithSolid(unitImg).WithSelectable("round", selection.Unit). - WithMove().WithOrder().Build()) - - // Area-select both units then right-click target — same gesture as in-game - game.UpdateSimulation(Input{EscapePressed: true}) - game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: -50}, LeftMouseDown: true}) - game.UpdateSimulation(Input{Cursor: physics.Point{X: 350, Y: 50}, LeftMouseUp: true}) - game.UpdateSimulation(Input{Cursor: target, RightMouseUp: true}) - - // MOVE_SPEED=10, cell=100 → 10 ticks per cell. B detours around A → ~60 ticks. - for range 60 { - game.UpdateSimulation(Input{}) - } - - assert.False(t, game.Move[unitA].IsActive, "Entity A should have stopped moving") - assert.False(t, game.Move[unitB].IsActive, "Entity B should have stopped moving") - assert.NotEqual(t, game.Position[unitA], game.Position[unitB], - "Entity A and B should NOT overlap: A at %s, B at %s", game.Position[unitA], game.Position[unitB]) -} - -func TestUnitDoesNotStopWhenDestinationOccupied(t *testing.T) { - // Unit B is far away, unit A is close. Both ordered to the same cell. - // A arrives first and settles. B should repath to an adjacent free cell - // rather than stopping mid-journey. - game := &Game{} - game.CurrentAction = Selecting - unitImg := NewFilledCircleImage(100, color.White) - - target := physics.Point{X: 500, Y: 0} - - unitA := game.Append(NewEntityBuilder(). - WithPosition(physics.Point{X: 400, Y: 0}). - WithSolid(unitImg).WithSelectable("round", selection.Unit). - WithMove().WithOrder().Build()) - unitB := game.Append(NewEntityBuilder(). - WithPosition(physics.Point{X: 0, Y: 0}). - WithSolid(unitImg).WithSelectable("round", selection.Unit). - WithMove().WithOrder().Build()) - - game.UpdateSimulation(Input{EscapePressed: true}) - game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: -50}, LeftMouseDown: true}) - game.UpdateSimulation(Input{Cursor: physics.Point{X: 450, Y: 50}, LeftMouseUp: true}) - game.UpdateSimulation(Input{Cursor: target, RightMouseUp: true}) - - // Run long enough for both units to settle (B travels ~5 cells) - for range 100 { - game.UpdateSimulation(Input{}) - } - - assert.False(t, game.Move[unitA].IsActive, "unit A should have stopped") - assert.False(t, game.Move[unitB].IsActive, "unit B should have stopped near target, not mid-journey") - assert.NotEqual(t, game.Position[unitA], game.Position[unitB], "units should not overlap") - // B should be adjacent to target, not stuck far away - assert.LessOrEqual(t, physics.Distance(game.Position[unitB], target), 150.0, - "unit B should be adjacent to target, got %s", game.Position[unitB]) -} 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") } diff --git a/order.go b/order.go deleted file mode 100644 index 9c3d643..0000000 --- a/order.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "log/slog" - - "age_of_empires/ecs" - "age_of_empires/monad" - "age_of_empires/physics" -) - -type OrderKind int - -const ( - OrderKindNone OrderKind = iota - OrderKindPatrol - OrderKindGather -) - -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 - } - 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 mask&ecs.CM_Spawn == ecs.CM_Spawn { - slog.Info("Setting spawn target") - spawn := &g.Spawn[mainEntity] - spawn.SpawnTarget = monad.Some(target) - return - } -} diff --git a/patrol_order.go b/patrol_order.go deleted file mode 100644 index e921e5b..0000000 --- a/patrol_order.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "age_of_empires/ecs" - "age_of_empires/physics" - - "golang.org/x/exp/slog" -) - -type PatrolOrder struct { - origin physics.Point - destination physics.Point -} - -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 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 - } - } -} diff --git a/resource.go b/resource.go deleted file mode 100644 index 5fb48ae..0000000 --- a/resource.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "time" - - "age_of_empires/ecs" -) - -type ResourceSource struct { - Remaining int -} - -type ResourceGatherer struct { - MaxCapacity int - CurrentVolume int - Source ecs.Entity - LastPickupTime time.Time -} - -type ResourceStorage struct{} diff --git a/spawn.go b/spawn.go deleted file mode 100644 index 87624fb..0000000 --- a/spawn.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "log/slog" - "time" - - "age_of_empires/ecs" - "age_of_empires/monad" - "age_of_empires/physics" -) - -type SpawnRequest struct { - Start time.Time -} - -type Spawn struct { - UnitResourceCost int - UnitSpawnDuration time.Duration - 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), - } -} - -func UpdateSpawnSystem(g *Game, now time.Time, masks []ecs.Mask, spawns []Spawn, positions []physics.Point) { - required := ecs.CM_Position | ecs.CM_Spawn - for i, mask := range masks { - if mask&required == required { - spawn := &spawns[i] - position := positions[i] - if len(spawn.Requests) == 0 || now.Sub(spawn.Requests[0].Start) < time.Duration(spawn.UnitSpawnDuration) { - continue - } - spawnPosition, _ := physics.Closest(position, physics.AdjacentPoints(position), g.getMoveMap()) - unit := g.Append(g.UnitBuilder.WithPosition(spawnPosition).Build()) - slog.Info("Spawned unit") - if spawn.SpawnTarget.Ok { - slog.Info("Unit has spawn target") - MainAction(g, unit, spawn.SpawnTarget.Value, g.getMoveMap()) - } - spawn.Requests = spawn.Requests[1:] - if len(spawn.Requests) > 0 { - spawn.Requests[0].Start = now - } - } - } -} -- cgit v1.3