From 1522b70398f42a58232752fd45fdb632d57fdd03 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Fri, 17 Apr 2026 22:18:50 +0200 Subject: [PATCH] Add drag & drop to relocate placed pieces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InputMapper tracks a mouse-down over a placed piece and promotes it to drag mode once the cursor travels past an 8px threshold. Legal drop cells (those where the piece's start→end vector still fits a legal placement) are highlighted in green. Releasing on a legal cell emits a RelocateRequested signal; Main feeds it to MovePieceCommand, which is already undoable via the existing history stack. Escape or releasing on an invalid cell cancels. The harness gains a relocate() helper so UI tests can script drag-and-drop moves without synthesizing motion events. --- Scripts/Automation/CommandDispatcher.cs | 18 +++ Scripts/Input/InputMapper.cs | 141 ++++++++++++++++++++++-- Scripts/Main.cs | 48 ++++++++ docs/PLAN.md | 6 - tools/automation/harness.py | 12 ++ tools/automation/test_relocate.py | 35 ++++++ 6 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 tools/automation/test_relocate.py diff --git a/Scripts/Automation/CommandDispatcher.cs b/Scripts/Automation/CommandDispatcher.cs index 9905fa9..657daf6 100644 --- a/Scripts/Automation/CommandDispatcher.cs +++ b/Scripts/Automation/CommandDispatcher.cs @@ -41,6 +41,7 @@ internal class CommandDispatcher "set_speed" => SetSpeed(args), "load_mission" => LoadMission(args), "back_to_menu" => BackToMenu(), + "relocate" => Relocate(args), "quick_save" => QuickSave(), "quick_load" => QuickLoad(), "undo" => Undo(), @@ -235,6 +236,23 @@ internal class CommandDispatcher }; } + private JsonNode? Relocate(JsonObject args) + { + var pieceId = args["pieceId"]!.GetValue(); + var newStart = ParseCoords(args["newStart"]); + var newEnd = ParseCoords(args["newEnd"]); + _facade.Input.EmitSignal(InputMapper.SignalName.RelocateRequested, + pieceId, newStart.Col, newStart.Row, newEnd.Col, newEnd.Row); + var sim = _facade.Sim(); + if (sim == null) return new JsonObject { ["relocated"] = false }; + var piece = sim.GetSnapshot().Pieces.FirstOrDefault(p => p.Id == pieceId); + return new JsonObject + { + ["relocated"] = piece != null && piece.StartCell == newStart && piece.EndCell == newEnd, + ["pieceId"] = pieceId + }; + } + private JsonNode? Undo() { var sim = _facade.Sim(); diff --git a/Scripts/Input/InputMapper.cs b/Scripts/Input/InputMapper.cs index 4dd28d3..8ef12fe 100644 --- a/Scripts/Input/InputMapper.cs +++ b/Scripts/Input/InputMapper.cs @@ -16,9 +16,13 @@ public partial class InputMapper : Node public delegate void CellClickedEventHandler(int col, int row); [Signal] public delegate void CancelledEventHandler(); + [Signal] + public delegate void RelocateRequestedEventHandler(int pieceId, int newStartCol, int newStartRow, int newEndCol, int newEndRow); public enum PlacementPhase { None, SelectingStart, SelectingEnd } + private const float DragThreshold = 8f; + private BoardView _boardView = null!; private PieceKind? _selectedKind; private Coords? _selectedStart; @@ -26,6 +30,11 @@ public partial class InputMapper : Node private BoardSnapshot? _snapshot; private Coords? _hoverCoords; + // Drag & drop of a placed piece + private int? _dragPieceId; + private Vector2 _dragMouseStart; + private bool _dragging; + public PlacementPhase CurrentPhase => _phase; public void Initialize(BoardView boardView) @@ -72,22 +81,140 @@ public partial class InputMapper : Node public override void _UnhandledInput(InputEvent @event) { - if (@event is InputEventMouseButton mouseEvent && mouseEvent.Pressed) + if (@event is InputEventMouseButton mouseEvent && mouseEvent.ButtonIndex == MouseButton.Left) { - if (mouseEvent.ButtonIndex == MouseButton.Left) - { - var localPos = _boardView.GetLocalMousePosition(); - GD.Print($"[InputMapper] LEFT CLICK — localPos={localPos}, phase={_phase}"); - HandleLeftClick(); - } + var localPos = _boardView.GetLocalMousePosition(); + if (mouseEvent.Pressed) + HandleLeftPress(localPos); + else + HandleLeftRelease(localPos); + } + + if (@event is InputEventMouseMotion && _dragPieceId != null) + { + var localPos = _boardView.GetLocalMousePosition(); + UpdateDrag(localPos); } if (@event is InputEventKey keyEvent && keyEvent.Pressed && keyEvent.Keycode == Key.Escape) { + CancelDrag(); Cancel(); } } + private void HandleLeftPress(Vector2 localPos) + { + // In placement mode, ignore drag behavior — click advances placement + if (_phase != PlacementPhase.None) return; + + var coords = _boardView.PixelToCoords(localPos); + if (coords == null || _snapshot == null) return; + + var piece = _snapshot.Pieces.FirstOrDefault( + p => p.StartCell == coords.Value || p.EndCell == coords.Value); + if (piece != null) + { + _dragPieceId = piece.Id; + _dragMouseStart = localPos; + _dragging = false; + } + } + + private void UpdateDrag(Vector2 localPos) + { + if (_dragPieceId == null) return; + + if (!_dragging && (localPos - _dragMouseStart).Length() > DragThreshold) + { + _dragging = true; + HighlightLegalDropsFor(_dragPieceId.Value); + } + } + + private void HandleLeftRelease(Vector2 localPos) + { + if (_dragging && _dragPieceId != null) + { + var dropCoords = _boardView.PixelToCoords(localPos); + TryRelocate(_dragPieceId.Value, dropCoords); + CancelDrag(); + return; + } + + // Normal click flow + CancelDrag(); + HandleLeftClick(); + } + + private void CancelDrag() + { + _dragPieceId = null; + _dragging = false; + _boardView.ClearHighlights(); + } + + private void HighlightLegalDropsFor(int pieceId) + { + var legal = ComputeLegalDrops(pieceId); + _boardView.ClearHighlights(); + _boardView.HighlightCells(legal, new Color("#44FF88AA")); + } + + private List ComputeLegalDrops(int pieceId) + { + var result = new List(); + if (_snapshot == null) return result; + + var piece = _snapshot.Pieces.FirstOrDefault(p => p.Id == pieceId); + if (piece == null) return result; + + var dc = piece.EndCell.Col - piece.StartCell.Col; + var dr = piece.EndCell.Row - piece.StartCell.Row; + + var boardState = GetBoardStateForValidation(); + if (boardState == null) return result; + + for (int c = 0; c < _snapshot.Width; c++) + { + for (int r = 0; r < _snapshot.Height; r++) + { + var newStart = new Coords(c, r); + var newEnd = new Coords(c + dc, r + dr); + if (!newEnd.IsOnBoard(_snapshot.Width, _snapshot.Height)) continue; + if (_snapshot.Grid[newStart.Col, newStart.Row] == CellType.Wall) continue; + if (_snapshot.Grid[newEnd.Col, newEnd.Row] == CellType.Wall) continue; + if (!MoveValidator.IsLegalPlacement(piece.Kind, newStart, newEnd, boardState)) continue; + result.Add(newStart); + } + } + return result; + } + + private void TryRelocate(int pieceId, Coords? dropCoords) + { + if (_snapshot == null || dropCoords == null) return; + var piece = _snapshot.Pieces.FirstOrDefault(p => p.Id == pieceId); + if (piece == null) return; + + var dc = piece.EndCell.Col - piece.StartCell.Col; + var dr = piece.EndCell.Row - piece.StartCell.Row; + + var newStart = dropCoords.Value; + var newEnd = new Coords(newStart.Col + dc, newStart.Row + dr); + + if (!newEnd.IsOnBoard(_snapshot.Width, _snapshot.Height)) return; + if (_snapshot.Grid[newStart.Col, newStart.Row] == CellType.Wall) return; + if (_snapshot.Grid[newEnd.Col, newEnd.Row] == CellType.Wall) return; + + var boardState = GetBoardStateForValidation(); + if (boardState == null) return; + if (!MoveValidator.IsLegalPlacement(piece.Kind, newStart, newEnd, boardState)) return; + + EmitSignal(SignalName.RelocateRequested, pieceId, + newStart.Col, newStart.Row, newEnd.Col, newEnd.Row); + } + private void HandleLeftClick() { var localPos = _boardView.GetLocalMousePosition(); diff --git a/Scripts/Main.cs b/Scripts/Main.cs index 788f762..7043d7f 100644 --- a/Scripts/Main.cs +++ b/Scripts/Main.cs @@ -422,6 +422,7 @@ public partial class Main : Node2D _pieceStockPanel.PieceSelected += OnPieceKindSelected; _inputMapper.PlacementRequested += OnPlacementRequested; _inputMapper.Cancelled += OnPlacementCancelled; + _inputMapper.RelocateRequested += OnRelocateRequested; _controlBar.PlayPressed += OnPlay; _controlBar.PausePressed += OnPause; _controlBar.StepPressed += OnStep; @@ -616,6 +617,53 @@ public partial class Main : Node2D _pieceStockPanel.ClearSelection(); } + private void OnRelocateRequested(int pieceId, int newStartCol, int newStartRow, int newEndCol, int newEndRow) + { + if (_sim == null) return; + var newStart = new Coords(newStartCol, newStartRow); + var newEnd = new Coords(newEndCol, newEndRow); + var events = _sim.ProcessCommand(new MovePieceCommand(pieceId, newStart, newEnd)); + foreach (var evt in events) + { + switch (evt) + { + case PieceMovedByPlayerEvent moved: + UpdatePieceVisualPosition(moved); + _detailPanel.Hide(); + break; + case CommandRejectedEvent rejected: + GD.Print($"Move rejected: {rejected.Reason}"); + break; + } + } + _inputMapper.SetSnapshot(_sim.GetSnapshot()); + } + + private void UpdatePieceVisualPosition(PieceMovedByPlayerEvent moved) + { + // Refresh from snapshot — the cleanest path is a full rebuild of this piece's visuals + if (_sim == null) return; + _eventAnimator.UnregisterPiece(moved.PieceId); + + var snap = _sim.GetSnapshot(); + var ps = snap.Pieces.FirstOrDefault(p => p.Id == moved.PieceId); + if (ps == null) return; + + var pieceView = new PieceView(); + pieceView.Setup(ps.Id, ps.Kind, ps.StartCell, ps.EndCell, _boardView); + _boardView.AddChild(pieceView); + + var color = PieceView.GetPieceColor(ps.Kind); + var trajectView = new TrajectView(); + trajectView.Setup(ps.Id, + _boardView.CoordsToPixel(ps.StartCell), + _boardView.CoordsToPixel(ps.EndCell), + color); + _boardView.AddChild(trajectView); + + _eventAnimator.RegisterPiece(ps.Id, pieceView, trajectView); + } + private void OnRemoveRequested(int pieceId) { if (_sim == null) return; diff --git a/docs/PLAN.md b/docs/PLAN.md index 2f16d8e..9475490 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -12,12 +12,6 @@ et l'extension de la campagne. Le moteur expose deja les commandes et events requis ; cote Godot il manque les surfaces d'interaction et d'animation. -### 1.1 Undo (Ctrl+Z) -Annule le dernier placement ou retrait. L'architecture event-sourcing rend -l'implementation naturelle : conserver un historique de commandes cote -presentation, rejouer l'etat sans la derniere. Essentiel pour l'iteration -rapide sur un reseau en temps reel. - ### 1.2 Drag & drop des pieces placees `MovePieceCommand` + `PieceMovedByPlayerEvent` existent cote engine. Cote Godot : permettre de glisser une piece placee pour deplacer son point de diff --git a/tools/automation/harness.py b/tools/automation/harness.py index 42a910d..a272cc3 100644 --- a/tools/automation/harness.py +++ b/tools/automation/harness.py @@ -277,6 +277,18 @@ class Harness: def undo(self) -> dict[str, Any]: return self.send("undo") + def relocate( + self, + piece_id: int, + new_start: tuple[int, int], + new_end: tuple[int, int], + ) -> dict[str, Any]: + return self.send("relocate", { + "pieceId": piece_id, + "newStart": list(new_start), + "newEnd": list(new_end), + }) + def quit(self) -> dict[str, Any]: return self.send("quit", timeout=5.0) diff --git a/tools/automation/test_relocate.py b/tools/automation/test_relocate.py new file mode 100644 index 0000000..65619ac --- /dev/null +++ b/tools/automation/test_relocate.py @@ -0,0 +1,35 @@ +"""End-to-end smoke test for piece relocation (drag & drop via IPC).""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tools.automation.harness import Harness + + +def main(): + with Harness.launch(run_name="relocate") as h: + h.load_mission("campaign_01", 0) + h.place("Pawn", (0, 0), (0, 1)) + h.screenshot("01_placed") + + s = h.state() + pid = s['pieces'][0]['id'] + assert s['pieces'][0]['start'] == [0, 0] + assert s['pieces'][0]['end'] == [0, 1] + + # Relocate pawn to (1,0)→(1,1) — vector preserved + r = h.relocate(pid, (1, 0), (1, 1)) + print(f"[relocate] {r}") + assert r['relocated'], r + h.screenshot("02_relocated") + + s = h.state() + assert s['pieces'][0]['start'] == [1, 0] + assert s['pieces'][0]['end'] == [1, 1] + + print("OK — relocation works") + + +if __name__ == "__main__": + main()