aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-05-07 12:07:02 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-05-07 12:07:02 +0200
commit20abde4dd780b51840e3498336bf907f0fd9164c (patch)
treea00d0fd7997651a1123bfbc9ccecb29d39615f1d
parent5c2d9ea10c9ed41fee41067c6d0d8d7fade8297c (diff)
Add Input abstraction between ebiten and simulation update
Introduces Input snapshot struct collected once per tick via CollectInput, and UpdateSimulation(Input) as the ebiten-free simulation entrypoint. Integration tests now drive the game through simulated inputs rather than direct method calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--README.md4
-rw-r--r--input.go36
-rw-r--r--integration_test.go32
-rw-r--r--main.go59
4 files changed, 87 insertions, 44 deletions
diff --git a/README.md b/README.md
index f906dd8..7a24235 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
## Testing
-- **Integration tests** Use `Update()` to run N ticks.
+- **Integration tests** Use `UpdateSimulation(Input{...})` to drive ticks with simulated inputs — no ebiten runtime needed.
- **Performance benchmarks**
## TODO
@@ -21,7 +21,7 @@
- [ ] ecs: bitfield for fast checking
- [ ] ecs: rename to systems
- [ ] perf: measure frame budget
-- [ ] abstraction between ebiten and update to handle inputs in a simulation?
+- [x] abstraction between ebiten and update to handle inputs in a simulation
- [ ] getMoveMap could be a maintained index
- [ ] entityAt could be a maintained index
diff --git a/input.go b/input.go
new file mode 100644
index 0000000..5b9be32
--- /dev/null
+++ b/input.go
@@ -0,0 +1,36 @@
+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
index 4525b18..23d106b 100644
--- a/integration_test.go
+++ b/integration_test.go
@@ -1,10 +1,12 @@
package main
import (
+ "image/color"
"testing"
"time"
"age_of_empires/physics"
+ "age_of_empires/selection"
"github.com/stretchr/testify/assert"
)
@@ -90,21 +92,22 @@ func TestGatherLoop(t *testing.T) {
// 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)
- unitID := game.Append(unitBuilder.Build())
+ game.Append(unitBuilder.Build())
// Set up simulated time
simTime := time.Now()
game.NowFunc = func() time.Time { return simTime }
- // Simulate user selection: select the unit
- game.GlobalSelection.Selected = append(game.GlobalSelection.Selected, unitID)
-
- // Simulate right-click on mine position to trigger Gather via MainAction
- minePosition := game.Position[mineID]
- MainAction(game, unitID, minePosition, game.getMoveMap())
+ // 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
@@ -114,7 +117,7 @@ func TestGatherLoop(t *testing.T) {
for i := 0; i < maxTicks; i++ {
simTime = simTime.Add(dt)
- game.Update()
+ game.UpdateSimulation(Input{})
if game.ResourceAmount > initialResources {
deposited = true
@@ -132,21 +135,26 @@ func TestTwoEntitiesMovingToSameCellDontOverlap(t *testing.T) {
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())
- moveMap := game.getMoveMap()
- MainAction(game, unitA, target, moveMap)
- MainAction(game, unitB, target, moveMap)
+ // 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.Update()
+ game.UpdateSimulation(Input{})
}
assert.False(t, game.Move[unitA].IsActive, "Entity A should have stopped moving")
diff --git a/main.go b/main.go
index 84ce87d..f0c7e51 100644
--- a/main.go
+++ b/main.go
@@ -14,7 +14,6 @@ import (
"age_of_empires/selection"
"github.com/hajimehoshi/ebiten/v2"
- "github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/martinlehoux/kagamigo/kcore"
"golang.org/x/exp/slog"
@@ -114,32 +113,28 @@ const (
KeyPatrolRequest = ebiten.KeyQ
)
-func (g *Game) updateSelecting(cursor physics.Point, moveMap physics.MoveMap) {
- if inpututil.IsKeyJustReleased(ebiten.KeyEscape) {
- g.GlobalSelection.Unselect(g.Selectable)
- }
- if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
- g.GlobalSelection.Start(cursor)
- sx, sy := ebiten.CursorPosition()
- g.GlobalSelection.StartScreen(physics.Point{X: sx, Y: sy})
+func (g *Game) updateSelecting(input Input, moveMap physics.MoveMap) {
+ if input.LeftMouseDown {
+ g.GlobalSelection.Start(input.Cursor)
+ g.GlobalSelection.StartScreen(input.ScreenCursor)
}
- if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight) {
- destination := cursor.Div(100).Mul(100)
+ 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 inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
- if g.GlobalSelection.IsActive(cursor) {
- if g.GlobalSelection.IsArea(cursor) {
- g.GlobalSelection.SelectMultiple(cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
+ 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(cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
+ g.GlobalSelection.SelectSingle(input.Cursor, g.Mask, g.Selectable, g.Position, g.RelBounds)
}
}
}
- if inpututil.IsKeyJustReleased(KeySpawnRequest) {
+ if input.SpawnPressed {
required := ecs.CM_Spawn
for _, i := range g.GlobalSelection.Selected {
if g.Mask[i]&required == required {
@@ -153,32 +148,36 @@ func (g *Game) updateSelecting(cursor physics.Point, moveMap physics.MoveMap) {
}
}
-func (g *Game) Update() error {
- g.Camera.Update(1.0 / 60.0)
- sx, sy := ebiten.CursorPosition()
- cursor := g.Camera.ScreenToWorld(sx, sy)
- if inpututil.IsKeyJustReleased(ebiten.KeyEscape) {
+func (g *Game) UpdateSimulation(input Input) {
+ moveMap := g.getMoveMap()
+
+ if input.EscapePressed {
+ g.GlobalSelection.Unselect(g.Selectable)
g.CurrentAction = Selecting
- slog.Info("selecting action")
- }
- if inpututil.IsKeyJustReleased(KeyPatrolRequest) {
+ } else if input.PatrolPressed {
g.CurrentAction = Patrolling
- slog.Info("patrolling action")
}
+
switch g.CurrentAction {
case Selecting:
- g.updateSelecting(cursor, g.getMoveMap())
+ g.updateSelecting(input, moveMap)
case Patrolling:
- if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight) {
- destination := cursor.Div(100).Mul(100)
+ 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(g.getMoveMap(), g.Mask, g.Move, g.Position)
+
+ 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
}