Add drag & drop to relocate placed pieces

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.
This commit is contained in:
Samuel Bouchet 2026-04-17 22:18:50 +02:00
parent 97bca7d7df
commit 1522b70398
6 changed files with 247 additions and 13 deletions

View file

@ -41,6 +41,7 @@ internal class CommandDispatcher
"set_speed" => SetSpeed(args), "set_speed" => SetSpeed(args),
"load_mission" => LoadMission(args), "load_mission" => LoadMission(args),
"back_to_menu" => BackToMenu(), "back_to_menu" => BackToMenu(),
"relocate" => Relocate(args),
"quick_save" => QuickSave(), "quick_save" => QuickSave(),
"quick_load" => QuickLoad(), "quick_load" => QuickLoad(),
"undo" => Undo(), "undo" => Undo(),
@ -235,6 +236,23 @@ internal class CommandDispatcher
}; };
} }
private JsonNode? Relocate(JsonObject args)
{
var pieceId = args["pieceId"]!.GetValue<int>();
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() private JsonNode? Undo()
{ {
var sim = _facade.Sim(); var sim = _facade.Sim();

View file

@ -16,9 +16,13 @@ public partial class InputMapper : Node
public delegate void CellClickedEventHandler(int col, int row); public delegate void CellClickedEventHandler(int col, int row);
[Signal] [Signal]
public delegate void CancelledEventHandler(); 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 } public enum PlacementPhase { None, SelectingStart, SelectingEnd }
private const float DragThreshold = 8f;
private BoardView _boardView = null!; private BoardView _boardView = null!;
private PieceKind? _selectedKind; private PieceKind? _selectedKind;
private Coords? _selectedStart; private Coords? _selectedStart;
@ -26,6 +30,11 @@ public partial class InputMapper : Node
private BoardSnapshot? _snapshot; private BoardSnapshot? _snapshot;
private Coords? _hoverCoords; private Coords? _hoverCoords;
// Drag & drop of a placed piece
private int? _dragPieceId;
private Vector2 _dragMouseStart;
private bool _dragging;
public PlacementPhase CurrentPhase => _phase; public PlacementPhase CurrentPhase => _phase;
public void Initialize(BoardView boardView) public void Initialize(BoardView boardView)
@ -72,22 +81,140 @@ public partial class InputMapper : Node
public override void _UnhandledInput(InputEvent @event) 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(); var localPos = _boardView.GetLocalMousePosition();
GD.Print($"[InputMapper] LEFT CLICK — localPos={localPos}, phase={_phase}"); if (mouseEvent.Pressed)
HandleLeftClick(); 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) if (@event is InputEventKey keyEvent && keyEvent.Pressed && keyEvent.Keycode == Key.Escape)
{ {
CancelDrag();
Cancel(); 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<Coords> ComputeLegalDrops(int pieceId)
{
var result = new List<Coords>();
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() private void HandleLeftClick()
{ {
var localPos = _boardView.GetLocalMousePosition(); var localPos = _boardView.GetLocalMousePosition();

View file

@ -422,6 +422,7 @@ public partial class Main : Node2D
_pieceStockPanel.PieceSelected += OnPieceKindSelected; _pieceStockPanel.PieceSelected += OnPieceKindSelected;
_inputMapper.PlacementRequested += OnPlacementRequested; _inputMapper.PlacementRequested += OnPlacementRequested;
_inputMapper.Cancelled += OnPlacementCancelled; _inputMapper.Cancelled += OnPlacementCancelled;
_inputMapper.RelocateRequested += OnRelocateRequested;
_controlBar.PlayPressed += OnPlay; _controlBar.PlayPressed += OnPlay;
_controlBar.PausePressed += OnPause; _controlBar.PausePressed += OnPause;
_controlBar.StepPressed += OnStep; _controlBar.StepPressed += OnStep;
@ -616,6 +617,53 @@ public partial class Main : Node2D
_pieceStockPanel.ClearSelection(); _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) private void OnRemoveRequested(int pieceId)
{ {
if (_sim == null) return; if (_sim == null) return;

View file

@ -12,12 +12,6 @@ et l'extension de la campagne.
Le moteur expose deja les commandes et events requis ; cote Godot il manque Le moteur expose deja les commandes et events requis ; cote Godot il manque
les surfaces d'interaction et d'animation. 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 ### 1.2 Drag & drop des pieces placees
`MovePieceCommand` + `PieceMovedByPlayerEvent` existent cote engine. `MovePieceCommand` + `PieceMovedByPlayerEvent` existent cote engine.
Cote Godot : permettre de glisser une piece placee pour deplacer son point de Cote Godot : permettre de glisser une piece placee pour deplacer son point de

View file

@ -277,6 +277,18 @@ class Harness:
def undo(self) -> dict[str, Any]: def undo(self) -> dict[str, Any]:
return self.send("undo") 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]: def quit(self) -> dict[str, Any]:
return self.send("quit", timeout=5.0) return self.send("quit", timeout=5.0)

View file

@ -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()