From 20abde4dd780b51840e3498336bf907f0fd9164c Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 7 May 2026 12:07:02 +0200 Subject: 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 --- README.md | 4 ++-- input.go | 36 ++++++++++++++++++++++++++++++++ integration_test.go | 32 ++++++++++++++++++----------- main.go | 59 ++++++++++++++++++++++++++--------------------------- 4 files changed, 87 insertions(+), 44 deletions(-) create mode 100644 input.go 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 } -- cgit v1.3