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:
parent
97bca7d7df
commit
1522b70398
6 changed files with 247 additions and 13 deletions
|
|
@ -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<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()
|
||||
{
|
||||
var sim = _facade.Sim();
|
||||
|
|
|
|||
|
|
@ -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 (mouseEvent.ButtonIndex == MouseButton.Left)
|
||||
if (@event is InputEventMouseButton mouseEvent && mouseEvent.ButtonIndex == MouseButton.Left)
|
||||
{
|
||||
var localPos = _boardView.GetLocalMousePosition();
|
||||
GD.Print($"[InputMapper] LEFT CLICK — localPos={localPos}, phase={_phase}");
|
||||
HandleLeftClick();
|
||||
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<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()
|
||||
{
|
||||
var localPos = _boardView.GetLocalMousePosition();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
35
tools/automation/test_relocate.py
Normal file
35
tools/automation/test_relocate.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue